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 bda1d565679e CAMEL-24237: TUI Catalog tab doc viewer and EIP scope
bda1d565679e is described below
commit bda1d565679e23a589afb3fab86f1abfe7d712bc
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Jul 23 07:48:10 2026 +0200
CAMEL-24237: TUI Catalog tab doc viewer and EIP scope
Add documentation viewer (d key), options viewer (o key), and table of
contents popup (t key) to the TUI Catalog tab. Extend EIP scope with
docs for doTry, a2aSubTask, tokenizer, pausable, and resumable patterns.
Add doc/options switching, type-ahead TOC navigation, scroll clamping,
and MCP tool support for catalog docs with includeDoc parameter.
Closes #25018
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
.../org/apache/camel/catalog/docs.properties | 5 +
.../apache/camel/catalog/docs/a2aSubTask-eip.adoc | 100 +++++
.../org/apache/camel/catalog/docs/doTry-eip.adoc | 246 +++++++++++
.../apache/camel/catalog/docs/pausable-eip.adoc | 54 +++
.../apache/camel/catalog/docs/resumable-eip.adoc | 56 +++
.../apache/camel/catalog/docs/tokenizer-eip.adoc | 109 +++++
.../src/main/docs/modules/eips/nav.adoc | 5 +
.../docs/modules/eips/pages/a2aSubTask-eip.adoc | 100 +++++
.../main/docs/modules/eips/pages/doTry-eip.adoc | 246 +++++++++++
.../main/docs/modules/eips/pages/pausable-eip.adoc | 54 +++
.../docs/modules/eips/pages/resumable-eip.adoc | 56 +++
.../docs/modules/eips/pages/tokenizer-eip.adoc | 109 +++++
.../camel/cli/connector/LocalCliConnector.java | 6 +-
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 58 ++-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 3 +
.../dsl/jbang/core/commands/tui/CatalogTab.java | 186 ++++++++-
.../jbang/core/commands/tui/DocViewerPopup.java | 153 ++++++-
.../jbang/core/commands/tui/MonitorContext.java | 9 +
.../core/commands/tui/OptionsViewerPopup.java | 462 +++++++++++++++++++++
.../dsl/jbang/core/commands/tui/TocPopup.java | 288 +++++++++++++
.../jbang/core/commands/tui/TuiToolRegistry.java | 84 +++-
21 files changed, 2347 insertions(+), 42 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
index 518dd4f70586..86aa824fbf01 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
@@ -1,6 +1,7 @@
a2a-component
a2a-consumer
a2a-producer
+a2aSubTask-eip
activemq-component
activemq6-component
aggregate-eip
@@ -152,6 +153,7 @@ disruptor-component
disruptor-vm-component
djl-component
dns-component
+doTry-eip
docker-component
docling-async-processing
docling-batch-processing
@@ -462,6 +464,7 @@ optaplanner-component
paho-component
paho-mqtt5-component
parquetAvro-dataformat
+pausable-eip
pdf-component
pg-replication-slot-component
pgevent-component
@@ -519,6 +522,7 @@ resourceresolver-github
rest-api-component
rest-component
rest-openapi-component
+resumable-eip
resume-strategies
return-address
robotframework-component
@@ -638,6 +642,7 @@ timer-component
to-eip
toD-eip
tokenize-language
+tokenizer-eip
topicLoadBalancer-eip
tracing
transactional-client
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/a2aSubTask-eip.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/a2aSubTask-eip.adoc
new file mode 100644
index 000000000000..00e9a1eeb39c
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/a2aSubTask-eip.adoc
@@ -0,0 +1,100 @@
+= A2A Sub Task EIP
+:doctitle: A2A Sub Task
+:shortname: a2aSubTask
+:description: Groups route steps and emits A2A (Agent-to-Agent) protocol
progress events before, after, or when the grouped work fails
+:since: 4.21
+:supportlevel: Stable
+:tabs-sync-option:
+
+The A2A Sub Task EIP groups route steps and emits
https://a2a-protocol.org/latest/specification/[A2A protocol] progress events
+before, after, or when the grouped work fails.
+
+This is useful for xref:ROOT:a2a-component.adoc[A2A] consumer routes where you
want to report scoped progress updates
+to the calling agent as the route processes different stages of work.
+
+The `emitBefore`, `emitAfter`, and `emitOnError` fields are optional and are
evaluated as
+xref:languages:simple-language.adoc[Simple] expressions against the current
Exchange.
+
+== EIP options
+
+// eip options: START
+include::partial$eip-options.adoc[]
+// eip options: END
+
+== Example
+
+In this example, the route groups two stages of work and emits progress events
for each:
+
+[tabs]
+====
+
+YAML::
++
+[source,yaml]
+----
+- route:
+ from:
+ uri: a2a:classpath:agent-card.json
+ steps:
+ - a2aSubTask:
+ id: search-docs-progress
+ emitBefore: "Searching docs..."
+ emitAfter: "Docs found: ${body.size()}"
+ emitOnError: "Error searching docs: ${exception.message}"
+ steps:
+ - to:
+ uri: "elasticsearch:docs?operation=Search"
+ - a2aSubTask:
+ id: draft-answer-progress
+ emitBefore: "Drafting answer..."
+ emitAfter: "Answer drafted: ${body}"
+ emitOnError: "Error drafting answer: ${exception.message}"
+ steps:
+ - bean:
+ ref: answerDraftingService
+ method: draft
+ - setBody:
+ simple: "Final answer: ${body}"
+----
+
+Java::
++
+[source,java]
+----
+from("a2a:classpath:agent-card.json")
+ .a2aSubTask()
+ .id("search-docs-progress")
+ .emitBefore("Searching docs...")
+ .emitAfter("Docs found: ${body.size()}")
+ .emitOnError("Error searching docs: ${exception.message}")
+ .to("elasticsearch:docs?operation=Search")
+ .end()
+ .a2aSubTask()
+ .id("draft-answer-progress")
+ .emitBefore("Drafting answer...")
+ .emitAfter("Answer drafted: ${body}")
+ .emitOnError("Error drafting answer: ${exception.message}")
+ .bean(answerDraftingService, "draft")
+ .end()
+ .setBody(simple("Final answer: ${body}"));
+----
+====
+
+== Behavior
+
+* The nested `steps` behave like normal Camel route steps.
+* If the nested steps fail, `emitOnError` can access the original exception
via `${exception.message}`,
+ and the original exception continues to propagate through the route.
+* Failures while evaluating the `emitBefore`, `emitAfter`, or `emitOnError`
expressions are route failures.
+* Failures while storing or notifying the resulting A2A progress event are
best-effort: they are logged
+ at debug level and do not stop nested work, fail an otherwise successful
exchange, or replace
+ the original nested-step exception.
+* By default, emitting progress outside an active A2A task context is a no-op.
+ Set `failIfNoTaskContext=true` when a route should fail if the current
Exchange
+ does not carry an active A2A task.
+* Use stable `id` values on long-lived sub-tasks so route tracing and
route-structure views keep stable node names.
+
+== See Also
+
+* xref:ROOT:a2a-component.adoc[A2A Component]
+* xref:others:a2a-consumer.adoc[A2A Consumer Guide]
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/doTry-eip.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/doTry-eip.adoc
new file mode 100644
index 000000000000..0b43a808862f
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/doTry-eip.adoc
@@ -0,0 +1,246 @@
+= Do Try EIP
+:doctitle: Do Try
+:shortname: doTry
+:description: Wraps route steps in a try-catch-finally block for fine-grained
exception handling within the route
+:since:
+:supportlevel: Stable
+:tabs-sync-option:
+
+Camel supports the Java equivalent of try, catch, and finally directly in the
DSL.
+It aims to work like its Java sisters but with more power.
+
+In Camel, we prefix the keywords with `do` to avoid having same keyword as
Java.
+So we have:
+
+- `doTry`
+- `doCatch`
+- `doFinally`
+- `end` to end the block in Java DSL
+
+[IMPORTANT]
+====
+When using `doTry ... doCatch ... doFinally`, the regular Camel
+xref:manual::error-handler.adoc[Error Handler] is not in use; meaning any
+`onException` or the likes does not trigger.
+
+The reason is that
+`doTry ... doCatch ... doFinally` is in fact its own error handler and mimics
how try/catch/finally works in Java.
+====
+
+== EIP options
+
+// eip options: START
+include::partial$eip-options.adoc[]
+// eip options: END
+
+== Using doTry ... doCatch ... doFinally
+
+In the route below we have all of them in action:
+
+[tabs]
+====
+
+Java::
++
+[source,java]
+----
+from("direct:start")
+ .doTry()
+ .process(new ProcessorFail())
+ .to("mock:result")
+ .doCatch(IOException.class, IllegalStateException.class)
+ .to("mock:catch")
+ .doFinally()
+ .to("mock:finally")
+ .end();
+----
+
+XML::
++
+[source,xml]
+----
+<route>
+ <from uri="direct:start"/>
+ <doTry>
+ <process ref="processorFail"/>
+ <to uri="mock:result"/>
+ <doCatch>
+ <exception>java.io.IOException</exception>
+ <exception>java.lang.IllegalStateException</exception>
+ <to uri="mock:catch"/>
+ </doCatch>
+ <doFinally>
+ <to uri="mock:finally"/>
+ </doFinally>
+ </doTry>
+</route>
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+ from:
+ uri: direct:start
+ steps:
+ - doTry:
+ steps:
+ - process:
+ ref: processorFail
+ - to:
+ uri: mock:result
+ - doCatch:
+ exception:
+ - java.io.IOException
+ - java.lang.IllegalStateException
+ steps:
+ - to:
+ uri: mock:catch
+ - doFinally:
+ steps:
+ - to:
+ uri: mock:finally
+----
+====
+
+=== Using onWhen with doCatch
+
+You can use xref:manual::predicate.adoc[Predicates] with `doCatch` to make it
determine at runtime if the block should be triggered or not.
+In this example, we only want to trigger if the caused exception message
contains the word *Damn*.
+
+[tabs]
+====
+
+Java::
++
+[source,java]
+----
+from("direct:start")
+ .doTry()
+ .process(new ProcessorFail())
+ .to("mock:result")
+ .doCatch(IOException.class, IllegalStateException.class)
+ .onWhen(exceptionMessage().contains("Damn"))
+ .to("mock:catch")
+ .doCatch(CamelExchangeException.class)
+ .to("mock:catchCamel")
+ .doFinally()
+ .to("mock:finally")
+ .end();
+----
+
+XML::
++
+[source,xml]
+----
+<route>
+ <from uri="direct:start"/>
+ <doTry>
+ <process ref="processorFail"/>
+ <to uri="mock:result"/>
+ <doCatch>
+ <exception>java.io.IOException</exception>
+ <exception>java.lang.IllegalStateException</exception>
+ <onWhen>
+ <simple>${exception.message} contains 'Damn'</simple>
+ </onWhen>
+ <to uri="mock:catch"/>
+ </doCatch>
+ <doCatch>
+ <exception>org.apache.camel.CamelExchangeException</exception>
+ <to uri="mock:catchCamel"/>
+ </doCatch>
+ <doFinally>
+ <to uri="mock:finally"/>
+ </doFinally>
+ </doTry>
+</route>
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+ from:
+ uri: direct:start
+ steps:
+ - doTry:
+ steps:
+ - process:
+ ref: processorFail
+ - to:
+ uri: mock:result
+ - doCatch:
+ exception:
+ - java.io.IOException
+ - java.lang.IllegalStateException
+ steps:
+ - onWhen:
+ expression:
+ simple:
+ expression: "${exception.message} contains 'Damn'"
+ - to:
+ uri: mock:catch
+ - doCatch:
+ exception:
+ - org.apache.camel.CamelExchangeException
+ steps:
+ - to:
+ uri: mock:catchCamel
+ - doFinally:
+ steps:
+ - to:
+ uri: mock:finally
+----
+====
+
+=== Use end() to end the block
+
+When using Java DSL you must use `end()` to indicate where the try ... catch
... finally block ends.
+If the route has a `doFinally`, then the `end()` should be at the end of the
finally block.
+If there is no `doFinally`, then the `end()` should be at the end of the last
`doCatch`.
+
+TIP: Instead of `end()` you can use `endDoTry()` to end and _return back_ to
the try ... catch scope.
+
+=== Nested doTry ... doCatch
+
+When nesting `doTry ... doCatch` from an outer `doTry ... doCatch` EIP,
+pay extra attention when using Java DSL as the Java programming language is
not _indent aware_
+so you may write code that is indented in a way where you think a catch block
is associated with the other doTry, but it is not.
+
+Given the following Java DSL:
+
+[source,java]
+----
+from("direct:test")
+ .doTry().
+ doTry().
+ throwException(new IllegalArgumentException("Forced by me"))
+ .doCatch(Exception.class)
+ .log("docatch 1")
+ .throwException(new IllegalArgumentException("Second forced by
me"))
+ .doCatch(Exception.class)
+ .log("docatch 2")
+ .end();
+----
+
+Both `docatch 1` and `docatch 2` are in the inner `doTry`, and the outer
`doTry` has no catch blocks.
+Use `endDoTry()` to end the blocks correctly:
+
+[source,java]
+----
+from("direct:test")
+ .doTry().
+ doTry().
+ throwException(new IllegalArgumentException("Forced by me"))
+ .doCatch(Exception.class)
+ .log("docatch 1")
+ .throwException(new IllegalArgumentException("Second forced by
me"))
+ .endDoTry() // end this doCatch block
+ .endDoTry() // end the inner doTry
+ .doCatch(Exception.class)
+ .log("docatch 2")
+ .end();
+----
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pausable-eip.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pausable-eip.adoc
new file mode 100644
index 000000000000..20bc377177a8
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/pausable-eip.adoc
@@ -0,0 +1,54 @@
+= Pausable EIP
+:doctitle: Pausable
+:shortname: pausable
+:description: Pauses a consumer based on a condition, allowing it to be
resumed later from the last known offset. Useful for controlling ingestion rate
on polling consumers.
+:since:
+:supportlevel: Stable
+:tabs-sync-option:
+
+The Pausable EIP provides pause and resume features for supported consumers.
+With this EIP, it is possible to implement logic that controls the behavior of
the consumer
+based on conditions that are external to the component. For instance, it makes
it possible
+to pause the consumer if an external system becomes unavailable.
+
+== EIP options
+
+// eip options: START
+include::partial$eip-options.adoc[]
+// eip options: END
+
+== Example
+
+To use the Pausable EIP, you need an instance of a consumer listener along
with a predicate
+that tests whether consumption should continue.
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("kafka:topic")
+ .pausable(new KafkaConsumerListener(), o -> canContinue())
+ .process(exchange -> LOG.info("Received: {}",
exchange.getMessage().getBody()))
+ .to("direct:destination");
+----
+====
+
+You can also integrate the Pausable EIP with the
xref:circuitBreaker-eip.adoc[Circuit Breaker EIP]
+to pause consumption based on downstream system availability:
+
+[source,java]
+----
+from("kafka:topic")
+ .pausable(new KafkaConsumerListener(), o -> canContinue())
+ .circuitBreaker()
+ .resilience4jConfiguration().circuitBreaker("pausableCircuit").end()
+ .to("direct:downstream")
+ .end();
+----
+
+== See Also
+
+* xref:resume-strategies.adoc[Resume Strategies]
+* xref:resumable-eip.adoc[Resumable EIP]
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/resumable-eip.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/resumable-eip.adoc
new file mode 100644
index 000000000000..702e92e4970a
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/resumable-eip.adoc
@@ -0,0 +1,56 @@
+= Resumable EIP
+:doctitle: Resumable
+:shortname: resumable
+:description: Enables resuming processing from the last known offset after a
restart, using a resume strategy to track and restore position
+:since:
+:supportlevel: Stable
+:tabs-sync-option:
+
+The Resumable EIP allows consuming data from the last known offset after a
restart,
+using a resume strategy to track and restore the position.
+
+This is useful when consuming large data sources where you want to avoid
reprocessing
+data that has already been consumed. The resume strategy points the consumer
to the exact
+offset to resume operations.
+
+== EIP options
+
+// eip options: START
+include::partial$eip-options.adoc[]
+// eip options: END
+
+== Example
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+KafkaResumeStrategyConfigurationBuilder kafkaConfigurationBuilder =
+ KafkaResumeStrategyConfigurationBuilder.newBuilder()
+ .withBootstrapServers("kafka-address:9092")
+ .withTopic("offset")
+ .withResumeCache(new MyChoiceOfResumeCache<>(100));
+
+from("some:component")
+ .resumable().configuration(kafkaConfigurationBuilder)
+ .process(this::process);
+----
+====
+
+=== Intermittent Mode
+
+Enable intermittent mode to avoid updating the offset for every exchange:
+
+[source,java]
+----
+from("some:component")
+ .resumable(new MyTestResumeStrategy()).intermittent(true)
+ .process(this::process);
+----
+
+== See Also
+
+* xref:resume-strategies.adoc[Resume Strategies]
+* xref:pausable-eip.adoc[Pausable EIP]
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/tokenizer-eip.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/tokenizer-eip.adoc
new file mode 100644
index 000000000000..db34196f5afa
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/tokenizer-eip.adoc
@@ -0,0 +1,109 @@
+= Specialized tokenizer for AI applications EIP
+:doctitle: Specialized tokenizer for AI applications
+:shortname: tokenizer
+:description: Tokenizes the message body for AI processing, splitting text
into chunks suitable for embedding or LLM input
+:since: 4.8
+:supportlevel: Stable
+:tabs-sync-option:
+
+The Tokenizer EIP provides support to tokenize (chunk) larger blocks of text
into text segments
+that can be used when interacting with LLMs. Tokenization is particularly
helpful when used with
+https://en.wikipedia.org/wiki/Vector_database[vector databases] to provide
better and more contextual search results
+for
https://en.wikipedia.org/wiki/Retrieval-augmented_generation[retrieval-augmented
generation (RAG)].
+
+This EIP uses the
https://docs.langchain4j.dev/tutorials/rag/#document-splitter[LangChain4j
document splitter]
+to handle chunking.
+
+== EIP options
+
+// eip options: START
+include::partial$eip-options.adoc[]
+// eip options: END
+
+== Supported Splitters
+
+The following type of splitters is supported:
+
+* By paragraph: using the DSL `tokenizer().byParagraph()`
+* By sentence: using the DSL `tokenizer().bySentence()`
+* By word: using the DSL `tokenizer().byWord()`
+* By line: using the DSL `tokenizer().byLine()`
+* By character: using the DSL `tokenizer().byCharacter()`
+
+== Supported Tokenizers
+
+The following tokenizers are supported:
+
+* OpenAI: using `LangChain4jTokenizerDefinition.TokenizerType.OPEN_AI`
+* Azure: using `LangChain4jTokenizerDefinition.TokenizerType.AZURE`
+* Qwen: using `LangChain4jTokenizerDefinition.TokenizerType.QWEN`
+
+== Example
+
+The tokenization creates a composite message (an array of Strings). This
composite message can be split
+using the xref:split-eip.adoc[Split EIP] so that each text segment is sent
separately to an endpoint.
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("direct:start")
+ .tokenize(tokenizer()
+ .byParagraph()
+ .maxSegmentSize(1024)
+ .maxOverlap(10)
+ .using(LangChain4jTokenizerDefinition.TokenizerType.OPEN_AI)
+ .end())
+ .split().body()
+ .to("mock:result");
+----
+====
+
+=== Tokenize by tokens
+
+Starting with Camel 4.12, the code defaults to using segment sizes for
determining the maximum amount of data to tokenize.
+To tokenize by tokens, you must specify the underlying model:
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("direct:start")
+ .tokenize(tokenizer()
+ .byParagraph()
+ .maxTokens(1024, "gpt-4o-mini")
+ .maxOverlap(10)
+ .using(LangChain4jTokenizerDefinition.TokenizerType.OPEN_AI)
+ .end())
+ .split().body()
+ .to("mock:result");
+----
+====
+
+== Dependencies
+
+Maven users will need to add the following dependency:
+
+[source,xml]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-langchain4j-tokenizer</artifactId>
+ <version>x.x.x</version>
+ <!-- use the same version as your Camel core version -->
+</dependency>
+----
+
+The application must also provide the specific tokenizer implementation from
LangChain4j:
+
+* OpenAI: `dev.langchain4j:langchain4j-open-ai`
+* Azure: `dev.langchain4j:langchain4j-azure-open-ai`
+* Qwen: `dev.langchain4j:langchain4j-dashscope`
+
+== See Also
+
+* xref:split-eip.adoc[Split EIP]
diff --git a/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
index 69a90e9d895a..1b595a329704 100644
--- a/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
+++ b/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
@@ -3,6 +3,7 @@
* xref:eips:enterprise-integration-patterns.adoc[Enterprise Integration
Patterns]
* xref:eips:ai-patterns.adoc[AI Patterns]
+** xref:a2aSubTask-eip.adoc[A2A Sub Task]
** xref:aggregate-eip.adoc[Aggregate]
** xref:batchConfig-eip.adoc[BatchConfig]
** xref:bean-eip.adoc[Bean]
@@ -22,6 +23,7 @@
** xref:customLoadBalancer-eip.adoc[Custom Load Balancer]
** xref:dead-letter-channel.adoc[Dead Letter Channel]
** xref:delay-eip.adoc[Delay]
+** xref:doTry-eip.adoc[Do Try]
** xref:durable-subscriber.adoc[Durable Subscriber]
** xref:dynamicRouter-eip.adoc[Dynamic Router]
** xref:enrich-eip.adoc[Enrich]
@@ -56,6 +58,7 @@
** xref:multicast-eip.adoc[Multicast]
** xref:normalizer.adoc[Normalizer]
** xref:onFallback-eip.adoc[On Fallback]
+** xref:pausable-eip.adoc[Pausable]
** xref:pipeline-eip.adoc[Pipeline]
** xref:point-to-point-channel.adoc[Point to Point Channel]
** xref:poll-eip.adoc[Poll]
@@ -75,6 +78,7 @@
** xref:resequence-eip.adoc[Resequence]
** xref:resilience4jConfiguration-eip.adoc[Resilience4j Configuration]
** xref:resilience4j-eip.adoc[Resilience4j EIP]
+** xref:resumable-eip.adoc[Resumable]
** xref:resume-strategies.adoc[Resume Strategies]
** xref:return-address.adoc[Return Address]
** xref:rollback-eip.adoc[Rollback]
@@ -93,6 +97,7 @@
** xref:setVariable-eip.adoc[Set Variable]
** xref:setVariables-eip.adoc[Set Variables]
** xref:sort-eip.adoc[Sort]
+** xref:tokenizer-eip.adoc[Specialized tokenizer for AI applications]
** xref:split-eip.adoc[Split]
** xref:step-eip.adoc[Step]
** xref:stickyLoadBalancer-eip.adoc[Sticky Load Balancer]
diff --git
a/core/camel-core-engine/src/main/docs/modules/eips/pages/a2aSubTask-eip.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/pages/a2aSubTask-eip.adoc
new file mode 100644
index 000000000000..00e9a1eeb39c
--- /dev/null
+++
b/core/camel-core-engine/src/main/docs/modules/eips/pages/a2aSubTask-eip.adoc
@@ -0,0 +1,100 @@
+= A2A Sub Task EIP
+:doctitle: A2A Sub Task
+:shortname: a2aSubTask
+:description: Groups route steps and emits A2A (Agent-to-Agent) protocol
progress events before, after, or when the grouped work fails
+:since: 4.21
+:supportlevel: Stable
+:tabs-sync-option:
+
+The A2A Sub Task EIP groups route steps and emits
https://a2a-protocol.org/latest/specification/[A2A protocol] progress events
+before, after, or when the grouped work fails.
+
+This is useful for xref:ROOT:a2a-component.adoc[A2A] consumer routes where you
want to report scoped progress updates
+to the calling agent as the route processes different stages of work.
+
+The `emitBefore`, `emitAfter`, and `emitOnError` fields are optional and are
evaluated as
+xref:languages:simple-language.adoc[Simple] expressions against the current
Exchange.
+
+== EIP options
+
+// eip options: START
+include::partial$eip-options.adoc[]
+// eip options: END
+
+== Example
+
+In this example, the route groups two stages of work and emits progress events
for each:
+
+[tabs]
+====
+
+YAML::
++
+[source,yaml]
+----
+- route:
+ from:
+ uri: a2a:classpath:agent-card.json
+ steps:
+ - a2aSubTask:
+ id: search-docs-progress
+ emitBefore: "Searching docs..."
+ emitAfter: "Docs found: ${body.size()}"
+ emitOnError: "Error searching docs: ${exception.message}"
+ steps:
+ - to:
+ uri: "elasticsearch:docs?operation=Search"
+ - a2aSubTask:
+ id: draft-answer-progress
+ emitBefore: "Drafting answer..."
+ emitAfter: "Answer drafted: ${body}"
+ emitOnError: "Error drafting answer: ${exception.message}"
+ steps:
+ - bean:
+ ref: answerDraftingService
+ method: draft
+ - setBody:
+ simple: "Final answer: ${body}"
+----
+
+Java::
++
+[source,java]
+----
+from("a2a:classpath:agent-card.json")
+ .a2aSubTask()
+ .id("search-docs-progress")
+ .emitBefore("Searching docs...")
+ .emitAfter("Docs found: ${body.size()}")
+ .emitOnError("Error searching docs: ${exception.message}")
+ .to("elasticsearch:docs?operation=Search")
+ .end()
+ .a2aSubTask()
+ .id("draft-answer-progress")
+ .emitBefore("Drafting answer...")
+ .emitAfter("Answer drafted: ${body}")
+ .emitOnError("Error drafting answer: ${exception.message}")
+ .bean(answerDraftingService, "draft")
+ .end()
+ .setBody(simple("Final answer: ${body}"));
+----
+====
+
+== Behavior
+
+* The nested `steps` behave like normal Camel route steps.
+* If the nested steps fail, `emitOnError` can access the original exception
via `${exception.message}`,
+ and the original exception continues to propagate through the route.
+* Failures while evaluating the `emitBefore`, `emitAfter`, or `emitOnError`
expressions are route failures.
+* Failures while storing or notifying the resulting A2A progress event are
best-effort: they are logged
+ at debug level and do not stop nested work, fail an otherwise successful
exchange, or replace
+ the original nested-step exception.
+* By default, emitting progress outside an active A2A task context is a no-op.
+ Set `failIfNoTaskContext=true` when a route should fail if the current
Exchange
+ does not carry an active A2A task.
+* Use stable `id` values on long-lived sub-tasks so route tracing and
route-structure views keep stable node names.
+
+== See Also
+
+* xref:ROOT:a2a-component.adoc[A2A Component]
+* xref:others:a2a-consumer.adoc[A2A Consumer Guide]
diff --git
a/core/camel-core-engine/src/main/docs/modules/eips/pages/doTry-eip.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/pages/doTry-eip.adoc
new file mode 100644
index 000000000000..0b43a808862f
--- /dev/null
+++ b/core/camel-core-engine/src/main/docs/modules/eips/pages/doTry-eip.adoc
@@ -0,0 +1,246 @@
+= Do Try EIP
+:doctitle: Do Try
+:shortname: doTry
+:description: Wraps route steps in a try-catch-finally block for fine-grained
exception handling within the route
+:since:
+:supportlevel: Stable
+:tabs-sync-option:
+
+Camel supports the Java equivalent of try, catch, and finally directly in the
DSL.
+It aims to work like its Java sisters but with more power.
+
+In Camel, we prefix the keywords with `do` to avoid having same keyword as
Java.
+So we have:
+
+- `doTry`
+- `doCatch`
+- `doFinally`
+- `end` to end the block in Java DSL
+
+[IMPORTANT]
+====
+When using `doTry ... doCatch ... doFinally`, the regular Camel
+xref:manual::error-handler.adoc[Error Handler] is not in use; meaning any
+`onException` or the likes does not trigger.
+
+The reason is that
+`doTry ... doCatch ... doFinally` is in fact its own error handler and mimics
how try/catch/finally works in Java.
+====
+
+== EIP options
+
+// eip options: START
+include::partial$eip-options.adoc[]
+// eip options: END
+
+== Using doTry ... doCatch ... doFinally
+
+In the route below we have all of them in action:
+
+[tabs]
+====
+
+Java::
++
+[source,java]
+----
+from("direct:start")
+ .doTry()
+ .process(new ProcessorFail())
+ .to("mock:result")
+ .doCatch(IOException.class, IllegalStateException.class)
+ .to("mock:catch")
+ .doFinally()
+ .to("mock:finally")
+ .end();
+----
+
+XML::
++
+[source,xml]
+----
+<route>
+ <from uri="direct:start"/>
+ <doTry>
+ <process ref="processorFail"/>
+ <to uri="mock:result"/>
+ <doCatch>
+ <exception>java.io.IOException</exception>
+ <exception>java.lang.IllegalStateException</exception>
+ <to uri="mock:catch"/>
+ </doCatch>
+ <doFinally>
+ <to uri="mock:finally"/>
+ </doFinally>
+ </doTry>
+</route>
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+ from:
+ uri: direct:start
+ steps:
+ - doTry:
+ steps:
+ - process:
+ ref: processorFail
+ - to:
+ uri: mock:result
+ - doCatch:
+ exception:
+ - java.io.IOException
+ - java.lang.IllegalStateException
+ steps:
+ - to:
+ uri: mock:catch
+ - doFinally:
+ steps:
+ - to:
+ uri: mock:finally
+----
+====
+
+=== Using onWhen with doCatch
+
+You can use xref:manual::predicate.adoc[Predicates] with `doCatch` to make it
determine at runtime if the block should be triggered or not.
+In this example, we only want to trigger if the caused exception message
contains the word *Damn*.
+
+[tabs]
+====
+
+Java::
++
+[source,java]
+----
+from("direct:start")
+ .doTry()
+ .process(new ProcessorFail())
+ .to("mock:result")
+ .doCatch(IOException.class, IllegalStateException.class)
+ .onWhen(exceptionMessage().contains("Damn"))
+ .to("mock:catch")
+ .doCatch(CamelExchangeException.class)
+ .to("mock:catchCamel")
+ .doFinally()
+ .to("mock:finally")
+ .end();
+----
+
+XML::
++
+[source,xml]
+----
+<route>
+ <from uri="direct:start"/>
+ <doTry>
+ <process ref="processorFail"/>
+ <to uri="mock:result"/>
+ <doCatch>
+ <exception>java.io.IOException</exception>
+ <exception>java.lang.IllegalStateException</exception>
+ <onWhen>
+ <simple>${exception.message} contains 'Damn'</simple>
+ </onWhen>
+ <to uri="mock:catch"/>
+ </doCatch>
+ <doCatch>
+ <exception>org.apache.camel.CamelExchangeException</exception>
+ <to uri="mock:catchCamel"/>
+ </doCatch>
+ <doFinally>
+ <to uri="mock:finally"/>
+ </doFinally>
+ </doTry>
+</route>
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+ from:
+ uri: direct:start
+ steps:
+ - doTry:
+ steps:
+ - process:
+ ref: processorFail
+ - to:
+ uri: mock:result
+ - doCatch:
+ exception:
+ - java.io.IOException
+ - java.lang.IllegalStateException
+ steps:
+ - onWhen:
+ expression:
+ simple:
+ expression: "${exception.message} contains 'Damn'"
+ - to:
+ uri: mock:catch
+ - doCatch:
+ exception:
+ - org.apache.camel.CamelExchangeException
+ steps:
+ - to:
+ uri: mock:catchCamel
+ - doFinally:
+ steps:
+ - to:
+ uri: mock:finally
+----
+====
+
+=== Use end() to end the block
+
+When using Java DSL you must use `end()` to indicate where the try ... catch
... finally block ends.
+If the route has a `doFinally`, then the `end()` should be at the end of the
finally block.
+If there is no `doFinally`, then the `end()` should be at the end of the last
`doCatch`.
+
+TIP: Instead of `end()` you can use `endDoTry()` to end and _return back_ to
the try ... catch scope.
+
+=== Nested doTry ... doCatch
+
+When nesting `doTry ... doCatch` from an outer `doTry ... doCatch` EIP,
+pay extra attention when using Java DSL as the Java programming language is
not _indent aware_
+so you may write code that is indented in a way where you think a catch block
is associated with the other doTry, but it is not.
+
+Given the following Java DSL:
+
+[source,java]
+----
+from("direct:test")
+ .doTry().
+ doTry().
+ throwException(new IllegalArgumentException("Forced by me"))
+ .doCatch(Exception.class)
+ .log("docatch 1")
+ .throwException(new IllegalArgumentException("Second forced by
me"))
+ .doCatch(Exception.class)
+ .log("docatch 2")
+ .end();
+----
+
+Both `docatch 1` and `docatch 2` are in the inner `doTry`, and the outer
`doTry` has no catch blocks.
+Use `endDoTry()` to end the blocks correctly:
+
+[source,java]
+----
+from("direct:test")
+ .doTry().
+ doTry().
+ throwException(new IllegalArgumentException("Forced by me"))
+ .doCatch(Exception.class)
+ .log("docatch 1")
+ .throwException(new IllegalArgumentException("Second forced by
me"))
+ .endDoTry() // end this doCatch block
+ .endDoTry() // end the inner doTry
+ .doCatch(Exception.class)
+ .log("docatch 2")
+ .end();
+----
diff --git
a/core/camel-core-engine/src/main/docs/modules/eips/pages/pausable-eip.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/pages/pausable-eip.adoc
new file mode 100644
index 000000000000..20bc377177a8
--- /dev/null
+++ b/core/camel-core-engine/src/main/docs/modules/eips/pages/pausable-eip.adoc
@@ -0,0 +1,54 @@
+= Pausable EIP
+:doctitle: Pausable
+:shortname: pausable
+:description: Pauses a consumer based on a condition, allowing it to be
resumed later from the last known offset. Useful for controlling ingestion rate
on polling consumers.
+:since:
+:supportlevel: Stable
+:tabs-sync-option:
+
+The Pausable EIP provides pause and resume features for supported consumers.
+With this EIP, it is possible to implement logic that controls the behavior of
the consumer
+based on conditions that are external to the component. For instance, it makes
it possible
+to pause the consumer if an external system becomes unavailable.
+
+== EIP options
+
+// eip options: START
+include::partial$eip-options.adoc[]
+// eip options: END
+
+== Example
+
+To use the Pausable EIP, you need an instance of a consumer listener along
with a predicate
+that tests whether consumption should continue.
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("kafka:topic")
+ .pausable(new KafkaConsumerListener(), o -> canContinue())
+ .process(exchange -> LOG.info("Received: {}",
exchange.getMessage().getBody()))
+ .to("direct:destination");
+----
+====
+
+You can also integrate the Pausable EIP with the
xref:circuitBreaker-eip.adoc[Circuit Breaker EIP]
+to pause consumption based on downstream system availability:
+
+[source,java]
+----
+from("kafka:topic")
+ .pausable(new KafkaConsumerListener(), o -> canContinue())
+ .circuitBreaker()
+ .resilience4jConfiguration().circuitBreaker("pausableCircuit").end()
+ .to("direct:downstream")
+ .end();
+----
+
+== See Also
+
+* xref:resume-strategies.adoc[Resume Strategies]
+* xref:resumable-eip.adoc[Resumable EIP]
diff --git
a/core/camel-core-engine/src/main/docs/modules/eips/pages/resumable-eip.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/pages/resumable-eip.adoc
new file mode 100644
index 000000000000..702e92e4970a
--- /dev/null
+++ b/core/camel-core-engine/src/main/docs/modules/eips/pages/resumable-eip.adoc
@@ -0,0 +1,56 @@
+= Resumable EIP
+:doctitle: Resumable
+:shortname: resumable
+:description: Enables resuming processing from the last known offset after a
restart, using a resume strategy to track and restore position
+:since:
+:supportlevel: Stable
+:tabs-sync-option:
+
+The Resumable EIP allows consuming data from the last known offset after a
restart,
+using a resume strategy to track and restore the position.
+
+This is useful when consuming large data sources where you want to avoid
reprocessing
+data that has already been consumed. The resume strategy points the consumer
to the exact
+offset to resume operations.
+
+== EIP options
+
+// eip options: START
+include::partial$eip-options.adoc[]
+// eip options: END
+
+== Example
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+KafkaResumeStrategyConfigurationBuilder kafkaConfigurationBuilder =
+ KafkaResumeStrategyConfigurationBuilder.newBuilder()
+ .withBootstrapServers("kafka-address:9092")
+ .withTopic("offset")
+ .withResumeCache(new MyChoiceOfResumeCache<>(100));
+
+from("some:component")
+ .resumable().configuration(kafkaConfigurationBuilder)
+ .process(this::process);
+----
+====
+
+=== Intermittent Mode
+
+Enable intermittent mode to avoid updating the offset for every exchange:
+
+[source,java]
+----
+from("some:component")
+ .resumable(new MyTestResumeStrategy()).intermittent(true)
+ .process(this::process);
+----
+
+== See Also
+
+* xref:resume-strategies.adoc[Resume Strategies]
+* xref:pausable-eip.adoc[Pausable EIP]
diff --git
a/core/camel-core-engine/src/main/docs/modules/eips/pages/tokenizer-eip.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/pages/tokenizer-eip.adoc
new file mode 100644
index 000000000000..db34196f5afa
--- /dev/null
+++ b/core/camel-core-engine/src/main/docs/modules/eips/pages/tokenizer-eip.adoc
@@ -0,0 +1,109 @@
+= Specialized tokenizer for AI applications EIP
+:doctitle: Specialized tokenizer for AI applications
+:shortname: tokenizer
+:description: Tokenizes the message body for AI processing, splitting text
into chunks suitable for embedding or LLM input
+:since: 4.8
+:supportlevel: Stable
+:tabs-sync-option:
+
+The Tokenizer EIP provides support to tokenize (chunk) larger blocks of text
into text segments
+that can be used when interacting with LLMs. Tokenization is particularly
helpful when used with
+https://en.wikipedia.org/wiki/Vector_database[vector databases] to provide
better and more contextual search results
+for
https://en.wikipedia.org/wiki/Retrieval-augmented_generation[retrieval-augmented
generation (RAG)].
+
+This EIP uses the
https://docs.langchain4j.dev/tutorials/rag/#document-splitter[LangChain4j
document splitter]
+to handle chunking.
+
+== EIP options
+
+// eip options: START
+include::partial$eip-options.adoc[]
+// eip options: END
+
+== Supported Splitters
+
+The following type of splitters is supported:
+
+* By paragraph: using the DSL `tokenizer().byParagraph()`
+* By sentence: using the DSL `tokenizer().bySentence()`
+* By word: using the DSL `tokenizer().byWord()`
+* By line: using the DSL `tokenizer().byLine()`
+* By character: using the DSL `tokenizer().byCharacter()`
+
+== Supported Tokenizers
+
+The following tokenizers are supported:
+
+* OpenAI: using `LangChain4jTokenizerDefinition.TokenizerType.OPEN_AI`
+* Azure: using `LangChain4jTokenizerDefinition.TokenizerType.AZURE`
+* Qwen: using `LangChain4jTokenizerDefinition.TokenizerType.QWEN`
+
+== Example
+
+The tokenization creates a composite message (an array of Strings). This
composite message can be split
+using the xref:split-eip.adoc[Split EIP] so that each text segment is sent
separately to an endpoint.
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("direct:start")
+ .tokenize(tokenizer()
+ .byParagraph()
+ .maxSegmentSize(1024)
+ .maxOverlap(10)
+ .using(LangChain4jTokenizerDefinition.TokenizerType.OPEN_AI)
+ .end())
+ .split().body()
+ .to("mock:result");
+----
+====
+
+=== Tokenize by tokens
+
+Starting with Camel 4.12, the code defaults to using segment sizes for
determining the maximum amount of data to tokenize.
+To tokenize by tokens, you must specify the underlying model:
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+from("direct:start")
+ .tokenize(tokenizer()
+ .byParagraph()
+ .maxTokens(1024, "gpt-4o-mini")
+ .maxOverlap(10)
+ .using(LangChain4jTokenizerDefinition.TokenizerType.OPEN_AI)
+ .end())
+ .split().body()
+ .to("mock:result");
+----
+====
+
+== Dependencies
+
+Maven users will need to add the following dependency:
+
+[source,xml]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-langchain4j-tokenizer</artifactId>
+ <version>x.x.x</version>
+ <!-- use the same version as your Camel core version -->
+</dependency>
+----
+
+The application must also provide the specific tokenizer implementation from
LangChain4j:
+
+* OpenAI: `dev.langchain4j:langchain4j-open-ai`
+* Azure: `dev.langchain4j:langchain4j-azure-open-ai`
+* Qwen: `dev.langchain4j:langchain4j-dashscope`
+
+== See Also
+
+* xref:split-eip.adoc[Split EIP]
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 b8886fe3b73e..9ac743df5eac 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
@@ -752,9 +752,9 @@ public class LocalCliConnector extends ServiceSupport
implements CliConnector, C
DevConsole dc =
camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
.resolveById("route-structure");
if (dc != null) {
- String filter = root.getString("filter");
- String brief = root.getString("brief");
- String metric = root.getString("metric");
+ String filter = root.getStringOrDefault("filter", "*");
+ String brief = root.getStringOrDefault("brief", "false");
+ String metric = root.getStringOrDefault("metric", "false");
JsonObject json
= (JsonObject) dc.call(DevConsole.MediaType.JSON,
Map.of("filter", filter, "brief", brief, "metric",
metric));
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 e29e3f22e96c..06005dc5b830 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
@@ -115,6 +115,7 @@ class ActionsPopup {
private final ThemePopup themePopup = new ThemePopup();
private final SettingsPopup settingsPopup = new SettingsPopup();
private final DocViewerPopup docViewerPopup = new DocViewerPopup();
+ private final OptionsViewerPopup optionsViewerPopup = new
OptionsViewerPopup();
private final ExampleBrowserPopup exampleBrowserPopup;
private final RunOptionsForm runOptionsForm = new RunOptionsForm();
@@ -353,6 +354,7 @@ class ActionsPopup {
|| folderInputPopup.isVisible()
|| runOptionsForm.isVisible()
|| docViewerPopup.isVisible()
+ || optionsViewerPopup.isVisible()
|| infraBrowserPopup.isVisible()
|| mcpLogPopup.isVisible() || aiLogPopup.isVisible() ||
doctorPopup.isVisible()
|| sendMessagePopup.isVisible() || stopAllPopup.isVisible() ||
captionOverlay.isInlineMode();
@@ -500,11 +502,22 @@ class ActionsPopup {
if (docViewerPopup.isVisible()) {
boolean wasPicker = docViewerPopup.isPickerVisible();
docViewerPopup.handleKeyEvent(ke);
- if (wasPicker && !docViewerPopup.isVisible() && ke.isCancel()) {
+ if (!docViewerPopup.isVisible() &&
docViewerPopup.consumeWantsOptions()) {
+ openOptions(docViewerPopup.getCatalogEntryName(),
docViewerPopup.getCatalogEntryKind(),
+ docViewerPopup.getCatalogEntryRef());
+ } else if (wasPicker && !docViewerPopup.isVisible() &&
ke.isCancel()) {
showActionsMenu = true;
}
return true;
}
+ if (optionsViewerPopup.isVisible()) {
+ optionsViewerPopup.handleKeyEvent(ke);
+ if (!optionsViewerPopup.isVisible() &&
optionsViewerPopup.consumeWantsDoc()) {
+ openDocForEntry(optionsViewerPopup.getCurrentName(),
optionsViewerPopup.getCurrentKind(),
+ optionsViewerPopup.getCurrentCatalog());
+ }
+ return true;
+ }
if (infraBrowserPopup.isVisible()) {
infraBrowserPopup.handleKeyEvent(ke);
if (!infraBrowserPopup.isVisible() && ke.isCancel()) {
@@ -763,6 +776,9 @@ class ActionsPopup {
if (docViewerPopup.isVisible()) {
return docViewerPopup.handleMouseEvent(me);
}
+ if (optionsViewerPopup.isVisible()) {
+ return optionsViewerPopup.handleMouseEvent(me);
+ }
if (folderInputPopup.isVisible()) {
return folderInputPopup.handleMouseEvent(me);
}
@@ -834,6 +850,9 @@ class ActionsPopup {
if (docViewerPopup.isVisible()) {
docViewerPopup.render(frame, area);
}
+ if (optionsViewerPopup.isVisible()) {
+ optionsViewerPopup.render(frame, area);
+ }
if (mcpLogPopup.isVisible()) {
mcpLogPopup.render(frame, area);
}
@@ -883,6 +902,10 @@ class ActionsPopup {
docViewerPopup.renderFooter(spans);
return;
}
+ if (optionsViewerPopup.isVisible()) {
+ optionsViewerPopup.renderFooter(spans);
+ return;
+ }
if (runOptionsForm.isVisible()) {
runOptionsForm.renderFooter(spans);
return;
@@ -1090,6 +1113,39 @@ class ActionsPopup {
}
}
+ void openMarkdown(String title, String markdown) {
+ showActionsMenu = false;
+ docViewerPopup.openMarkdown(title, markdown);
+ }
+
+ void openOptions(String name, String kind,
org.apache.camel.catalog.CamelCatalog catalog) {
+ showActionsMenu = false;
+ optionsViewerPopup.open(name, kind, catalog);
+ }
+
+ void openCatalogDoc(String name, String kind,
org.apache.camel.catalog.CamelCatalog catalog) {
+ showActionsMenu = false;
+ openDocForEntry(name, kind, catalog);
+ }
+
+ private void openDocForEntry(String name, String kind,
org.apache.camel.catalog.CamelCatalog catalog) {
+ if (catalog == null) {
+ return;
+ }
+ String docName = switch (kind) {
+ case "component" -> name + "-component";
+ case "dataformat" -> name + "-dataformat";
+ case "language" -> name + "-language";
+ case "eip" -> name + "-eip";
+ default -> name;
+ };
+ String adoc = catalog.asciiDoc(docName);
+ if (adoc != null) {
+ String md = DocHelper.asciidocToMarkdown(adoc);
+ docViewerPopup.openCatalogDoc(name, md, name, kind, catalog);
+ }
+ }
+
private void setNotification(String msg, boolean error) {
if (notificationCallback != null) {
notificationCallback.accept(msg, error);
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 c4807f6c9891..7d8ee4726e57 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
@@ -268,6 +268,9 @@ public class CamelMonitor extends CamelCommand {
actionsPopup.setMonitorContext(ctx);
actionsPopup.setNotificationCallback((msg, error) ->
setNotification(msg, error));
ctx.notificationCallback = (msg, error) -> setNotification(msg, error);
+ ctx.openMarkdownCallback = actionsPopup::openMarkdown;
+ ctx.openOptionsCallback = actionsPopup::openOptions;
+ ctx.openCatalogDocCallback = actionsPopup::openCatalogDoc;
actionsPopup.setResetStatsAction(this::resetStats);
shellPanel.setContext(ctx);
aiPanel.setContext(ctx);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CatalogTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CatalogTab.java
index 825790433d5e..6c34acfb0ed6 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CatalogTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CatalogTab.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
@@ -43,7 +44,9 @@ import dev.tamboui.widgets.table.Row;
import dev.tamboui.widgets.table.Table;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.dsl.jbang.core.common.CatalogLoader;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
import org.apache.camel.tooling.model.ArtifactModel;
+import org.apache.camel.tooling.model.EipModel;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -55,7 +58,7 @@ import static
org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.*;
*/
class CatalogTab extends AbstractTableTab {
- private static final String[] SCOPES = { "all", "component", "dataformat",
"language", "other" };
+ private static final String[] SCOPES = { "all", "component", "dataformat",
"language", "other", "eip" };
private final AtomicBoolean loading = new AtomicBoolean(false);
@@ -64,6 +67,7 @@ class CatalogTab extends AbstractTableTab {
private String filterTerm;
private int scopeIndex;
private boolean fullCatalog;
+ private CamelCatalog catalog;
private List<CatalogEntry> allEntries = Collections.emptyList();
private List<CatalogEntry> filteredEntries = Collections.emptyList();
private String lastPid;
@@ -94,6 +98,7 @@ class CatalogTab extends AbstractTableTab {
filterTerm = null;
filterInputActive = false;
scopeIndex = 0;
+ catalog = null;
lastPid = null;
errorMessage = null;
dataLoaded = false;
@@ -134,6 +139,14 @@ class CatalogTab extends AbstractTableTab {
loadCatalogData();
return true;
}
+ if (ke.isCharIgnoreCase('d')) {
+ openDocViewer();
+ return true;
+ }
+ if (ke.isCharIgnoreCase('o')) {
+ openOptionsViewer();
+ return true;
+ }
return false;
}
@@ -297,11 +310,13 @@ class CatalogTab extends AbstractTableTab {
addDetailField(lines, "Title", entry.title, area.width());
addDetailField(lines, "Description", entry.description, area.width());
addDetailField(lines, "Kind", entry.kind, area.width());
- String maven = entry.groupId + ":" + entry.artifactId;
- if (entry.version != null) {
- maven += ":" + entry.version;
+ if (entry.groupId != null) {
+ String maven = entry.groupId + ":" + entry.artifactId;
+ if (entry.version != null) {
+ maven += ":" + entry.version;
+ }
+ addDetailField(lines, "Maven", maven, area.width());
}
- addDetailField(lines, "Maven", maven, area.width());
if (entry.firstVersion != null) {
addDetailField(lines, "Since", entry.firstVersion, area.width());
}
@@ -334,6 +349,34 @@ class CatalogTab extends AbstractTableTab {
area);
}
+ private void openDocViewer() {
+ if (ctx.openCatalogDocCallback == null || catalog == null) {
+ return;
+ }
+ List<CatalogEntry> sorted = new ArrayList<>(filteredEntries);
+ sorted.sort(this::sortEntry);
+ Integer sel = tableState.selected();
+ if (sel == null || sel < 0 || sel >= sorted.size()) {
+ return;
+ }
+ CatalogEntry entry = sorted.get(sel);
+ ctx.openCatalogDocCallback.accept(entry.name, entry.kind, catalog);
+ }
+
+ private void openOptionsViewer() {
+ if (ctx.openOptionsCallback == null || catalog == null) {
+ return;
+ }
+ List<CatalogEntry> sorted = new ArrayList<>(filteredEntries);
+ sorted.sort(this::sortEntry);
+ Integer sel = tableState.selected();
+ if (sel == null || sel < 0 || sel >= sorted.size()) {
+ return;
+ }
+ CatalogEntry entry = sorted.get(sel);
+ ctx.openOptionsCallback.accept(entry.name, entry.kind, catalog);
+ }
+
private void addDetailField(List<Line> lines, String label, String value,
int width) {
if (value == null || value.isEmpty()) {
return;
@@ -377,6 +420,8 @@ class CatalogTab extends AbstractTableTab {
hint(spans, "s", "sort");
hint(spans, "a", fullCatalog ? "app only" : "all");
hint(spans, "f", "scope [" + SCOPES[scopeIndex] + "]");
+ hint(spans, "d", "doc");
+ hint(spans, "o", "options");
if (filterTerm != null) {
spans.add(Span.styled(" /", Theme.label().bold()));
spans.add(Span.raw("\"" + filterTerm + "\" "));
@@ -400,6 +445,7 @@ class CatalogTab extends AbstractTableTab {
case "component" -> Style.EMPTY.fg(Theme.accent());
case "dataformat" -> Theme.success();
case "language" -> Theme.warning();
+ case "eip" -> Theme.info();
default -> Style.EMPTY.dim();
};
}
@@ -422,7 +468,7 @@ class CatalogTab extends AbstractTableTab {
if (!full) {
DependencyLoader.LoadResult result =
DependencyLoader.loadDependencies(info);
if (result.error() != null && result.entries().isEmpty()) {
- applyResult(Collections.emptyList(), result.error());
+ applyResult(Collections.emptyList(), null,
result.error());
return;
}
appArtifacts = new HashSet<>();
@@ -433,22 +479,23 @@ class CatalogTab extends AbstractTableTab {
}
}
- CamelCatalog catalog = CatalogLoader.loadCatalog(null,
info.camelVersion, true);
- if (catalog == null) {
- applyResult(Collections.emptyList(), "Could not load
catalog for version " + info.camelVersion);
+ CamelCatalog cat = CatalogLoader.loadCatalog(null,
info.camelVersion, true);
+ if (cat == null) {
+ applyResult(Collections.emptyList(), null, "Could not load
catalog for version " + info.camelVersion);
return;
}
List<CatalogEntry> entries = new ArrayList<>();
- collectArtifacts(catalog, "component",
catalog.findComponentNames(), appArtifacts, entries);
- collectArtifacts(catalog, "dataformat",
catalog.findDataFormatNames(), appArtifacts, entries);
- collectArtifacts(catalog, "language",
catalog.findLanguageNames(), appArtifacts, entries);
- collectArtifacts(catalog, "other", catalog.findOtherNames(),
appArtifacts, entries);
+ collectArtifacts(cat, "component", cat.findComponentNames(),
appArtifacts, entries);
+ collectArtifacts(cat, "dataformat", cat.findDataFormatNames(),
appArtifacts, entries);
+ collectArtifacts(cat, "language", cat.findLanguageNames(),
appArtifacts, entries);
+ collectArtifacts(cat, "other", cat.findOtherNames(),
appArtifacts, entries);
+ collectEips(cat, info.pid, full, entries);
entries.sort((a, b) -> a.name.compareToIgnoreCase(b.name));
- applyResult(entries, null);
+ applyResult(entries, cat, null);
} catch (Exception e) {
- applyResult(Collections.emptyList(), "Error: " +
e.getMessage());
+ applyResult(Collections.emptyList(), null, "Error: " +
e.getMessage());
} finally {
loading.set(false);
}
@@ -499,12 +546,85 @@ class CatalogTab extends AbstractTableTab {
}
}
- private void applyResult(List<CatalogEntry> entries, String error) {
+ @SuppressWarnings("unchecked")
+ private void collectEips(CamelCatalog catalog, String pid, boolean full,
List<CatalogEntry> entries) {
+ try {
+ Set<String> eipNames;
+ if (full) {
+ eipNames = new HashSet<>(catalog.findModelNames());
+ } else {
+ eipNames = new HashSet<>();
+ Path outputFile = ctx.getOutputFile(pid);
+ PathUtils.deleteFile(outputFile);
+
+ JsonObject root = new JsonObject();
+ root.put("action", "route-structure");
+ root.put("filter", "*");
+ root.put("brief", true);
+ root.put("metric", false);
+ PathUtils.writeTextSafely(root.toJson(),
ctx.getActionFile(pid));
+
+ JsonObject jo = TuiHelper.pollJsonResponse(outputFile, 5000);
+ PathUtils.deleteFile(outputFile);
+ if (jo == null) {
+ return;
+ }
+
+ List<JsonObject> routes = (List<JsonObject>)
jo.getCollection("routes");
+ if (routes != null) {
+ for (JsonObject route : routes) {
+ List<JsonObject> code = (List<JsonObject>)
route.getCollection("code");
+ if (code != null) {
+ for (JsonObject node : code) {
+ String type = node.getString("type");
+ if (type != null) {
+ eipNames.add(type);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Set<String> skipEips = Set.of("when", "otherwise",
+ "langChain4jCharacterTokenizer",
"langChain4jLineTokenizer",
+ "langChain4jParagraphTokenizer",
"langChain4jSentenceTokenizer",
+ "langChain4jWordTokenizer");
+ for (String name : eipNames) {
+ if (skipEips.contains(name)) {
+ continue;
+ }
+ EipModel model = catalog.eipModel(name);
+ if (model == null) {
+ continue;
+ }
+ if (full && (model.getLabel() == null ||
!model.getLabel().contains("eip"))) {
+ continue;
+ }
+ CatalogEntry entry = new CatalogEntry();
+ entry.name = model.getName();
+ entry.kind = "eip";
+ entry.title = model.getTitle() != null ? model.getTitle() :
name;
+ entry.description = model.getDescription() != null ?
model.getDescription() : "";
+ entry.label = model.getLabel();
+ entry.firstVersion = model.getFirstVersion();
+ entry.deprecated = model.isDeprecated();
+ entry.deprecatedSince = model.getDeprecatedSince();
+ entry.deprecationNote = model.getDeprecationNote();
+ entries.add(entry);
+ }
+ } catch (Exception e) {
+ // ignore - EIP detection is best-effort
+ }
+ }
+
+ private void applyResult(List<CatalogEntry> entries, CamelCatalog cat,
String error) {
if (ctx.runner == null) {
return;
}
ctx.runner.runOnRenderThread(() -> {
allEntries = entries;
+ catalog = cat;
errorMessage = error;
dataLoaded = true;
refilter();
@@ -516,6 +636,9 @@ class CatalogTab extends AbstractTableTab {
String ft = filterTerm != null ? filterTerm.toLowerCase() : null;
String scope = SCOPES[scopeIndex];
for (CatalogEntry entry : allEntries) {
+ if ("all".equals(scope) && "eip".equals(entry.kind)) {
+ continue;
+ }
if (!"all".equals(scope) && !scope.equals(entry.kind)) {
continue;
}
@@ -607,18 +730,45 @@ class CatalogTab extends AbstractTableTab {
- **dataformat** — show only data formats
- **language** — show only expression languages
- **other** — show only miscellaneous artifacts
+ - **eip** — show EIPs detected from the running app's routes
## Filter
Press `/` to open the filter input. Type a search term and
press
`Enter` to filter by substring match on name, title, or label.
+ ## Documentation
+
+ Press `d` to open the full documentation for the selected
artifact in
+ a scrollable viewer. The documentation is loaded from the
Camel catalog
+ and converted from AsciiDoc to Markdown. Use
`↑`/`↓`/`PgUp`/`PgDn` or
+ mouse scroll to navigate, and `Esc` to close.
+
+ ## Options
+
+ Press `o` to open the options viewer for the selected
artifact. This
+ shows all configuration options in a structured format with
types,
+ defaults, enum values, groups, and descriptions.
+
+ For **components**, the viewer has three tabs:
+ - **Component** — component-level options
+ - **Endpoint** — endpoint-level options
+ - **Headers** — message headers with constant names
+
+ For **data formats**, **languages**, and **others**, a single
Options tab
+ is shown. For **EIPs**, Options and exchange Properties tabs
are shown.
+
+ Press `←`/`→` or `Tab` to switch tabs. Use
`↑`/`↓`/`PgUp`/`PgDn` or
+ mouse scroll to navigate, and `Esc` to close.
+
## Keys
- `s` — cycle sort column (name, kind, description)
- `S` — reverse sort order
- `a` — toggle app only / full catalog
- `f` — cycle scope
+ - `d` — open documentation viewer
+ - `o` — open options viewer
- `/` — open filter
- `Esc` — clear filter or back
""";
@@ -643,7 +793,9 @@ class CatalogTab extends AbstractTableTab {
if (e.label != null) {
row.put("label", e.label);
}
- row.put("artifactId", e.artifactId);
+ if (e.artifactId != null) {
+ row.put("artifactId", e.artifactId);
+ }
if (e.firstVersion != null) {
row.put("since", e.firstVersion);
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DocViewerPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DocViewerPopup.java
index c0db7d7a22dc..5d339be97f73 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DocViewerPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DocViewerPopup.java
@@ -23,6 +23,8 @@ import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Collectors;
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
import dev.tamboui.layout.Rect;
import dev.tamboui.markdown.MarkdownView;
import dev.tamboui.style.Overflow;
@@ -44,6 +46,8 @@ 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 dev.tamboui.widgets.scrollbar.Scrollbar;
+import dev.tamboui.widgets.scrollbar.ScrollbarState;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint;
@@ -58,7 +62,15 @@ class DocViewerPopup {
private String docTitle;
private int docScroll;
private Runnable onCloseCallback;
-
+ private String catalogEntryName;
+ private String catalogEntryKind;
+ private org.apache.camel.catalog.CamelCatalog catalogEntryRef;
+ private boolean wantsOptions;
+ private final TocPopup tocPopup = new TocPopup();
+ private int lastContentWidth;
+ private int lastTotalHeight;
+
+ private final ScrollbarState scrollbarState = new ScrollbarState();
private final ListState pickerState = new ListState();
private List<IntegrationInfo> pickerIntegrations;
private Rect pickerRect;
@@ -105,10 +117,31 @@ class DocViewerPopup {
return pickerIntegrations;
}
+ boolean consumeWantsOptions() {
+ boolean v = wantsOptions;
+ wantsOptions = false;
+ return v;
+ }
+
+ String getCatalogEntryName() {
+ return catalogEntryName;
+ }
+
+ String getCatalogEntryKind() {
+ return catalogEntryKind;
+ }
+
+ org.apache.camel.catalog.CamelCatalog getCatalogEntryRef() {
+ return catalogEntryRef;
+ }
+
void close() {
showViewer = false;
showPicker = false;
onCloseCallback = null;
+ catalogEntryName = null;
+ catalogEntryKind = null;
+ catalogEntryRef = null;
}
void openMarkdown(String title, String markdown) {
@@ -116,6 +149,7 @@ class DocViewerPopup {
docContent = markdown;
docTitle = title;
docScroll = 0;
+ lastTotalHeight = 0;
showViewer = true;
onCloseCallback = null;
}
@@ -125,6 +159,15 @@ class DocViewerPopup {
this.onCloseCallback = onClose;
}
+ void openCatalogDoc(
+ String title, String markdown, String entryName, String entryKind,
+ org.apache.camel.catalog.CamelCatalog catalog) {
+ openMarkdown(title, markdown);
+ this.catalogEntryName = entryName;
+ this.catalogEntryKind = entryKind;
+ this.catalogEntryRef = catalog;
+ }
+
void openLines(String title, List<Line> lines) {
docContent = null;
docLines = lines;
@@ -136,6 +179,14 @@ class DocViewerPopup {
boolean handleKeyEvent(KeyEvent ke) {
if (showViewer) {
+ if (tocPopup.isVisible()) {
+ tocPopup.handleKeyEvent(ke);
+ TocPopup.TocEntry entry = tocPopup.consumePendingEntry();
+ if (entry != null) {
+ jumpToHeading(entry);
+ }
+ return true;
+ }
if (ke.isCancel()) {
showViewer = false;
Runnable cb = onCloseCallback;
@@ -143,6 +194,14 @@ class DocViewerPopup {
if (cb != null) {
cb.run();
}
+ } else if (ke.isCharIgnoreCase('t') && docContent != null) {
+ List<TocPopup.TocEntry> headings =
TocPopup.extractHeadings(docContent);
+ if (!headings.isEmpty()) {
+ tocPopup.open(headings);
+ }
+ } else if (ke.isCharIgnoreCase('o') && catalogEntryName != null) {
+ wantsOptions = true;
+ showViewer = false;
} else if (ke.isUp() || ke.isChar('k')) {
docScroll = Math.max(0, docScroll - 1);
} else if (ke.isDown() || ke.isChar('j')) {
@@ -177,6 +236,14 @@ class DocViewerPopup {
boolean handleMouseEvent(MouseEvent me) {
if (showViewer) {
+ if (tocPopup.isVisible()) {
+ tocPopup.handleMouseEvent(me);
+ TocPopup.TocEntry entry = tocPopup.consumePendingEntry();
+ if (entry != null) {
+ jumpToHeading(entry);
+ }
+ return true;
+ }
if (me.kind() == MouseEventKind.SCROLL_UP) {
docScroll = Math.max(0, docScroll - 3);
return true;
@@ -190,6 +257,19 @@ class DocViewerPopup {
return false;
}
+ private void jumpToHeading(TocPopup.TocEntry entry) {
+ if (docContent == null || lastContentWidth <= 0) {
+ return;
+ }
+ String prefix = docContent.substring(0, Math.min(entry.charOffset(),
docContent.length()));
+ int offset = MarkdownView.builder()
+ .source(prefix)
+ .styles(Theme.markdownStyles())
+ .build()
+ .computeHeight(lastContentWidth);
+ docScroll = offset;
+ }
+
void render(Frame frame, Rect area) {
if (showPicker) {
renderPicker(frame, area);
@@ -201,8 +281,14 @@ class DocViewerPopup {
void renderFooter(List<Span> spans) {
if (showViewer) {
+ hint(spans, "Esc", "back");
hint(spans, "↑↓", "scroll");
- hintLast(spans, "Esc", "back");
+ if (docContent != null) {
+ hint(spans, "t", "toc");
+ }
+ if (catalogEntryName != null) {
+ hintLast(spans, "o", "options");
+ }
} else if (showPicker) {
hint(spans, "↑↓", "navigate");
hint(spans, "Enter", "view");
@@ -399,7 +485,6 @@ class DocViewerPopup {
private void renderViewer(Frame frame, Rect area) {
frame.renderWidget(Clear.INSTANCE, area);
- Rect popup = new Rect(area.left() + 2, area.top() + 1, area.width() -
4, area.height() - 2);
Title title;
if (docTitle != null && docTitle.startsWith("Failed:")) {
String rest = docTitle.substring("Failed:".length());
@@ -409,16 +494,35 @@ class DocViewerPopup {
} else {
title = Title.from(" " + docTitle + " ");
}
+ List<Span> footerSpans = new ArrayList<>();
+ footerSpans.add(Span.styled(" Esc ", Theme.hintKey()));
+ footerSpans.add(Span.raw(" back "));
+ footerSpans.add(Span.styled(" ↑↓ ", Theme.hintKey()));
+ footerSpans.add(Span.raw(" scroll "));
+ if (docContent != null) {
+ footerSpans.add(Span.styled(" t ", Theme.hintKey()));
+ footerSpans.add(Span.raw(" toc "));
+ }
+ if (catalogEntryName != null) {
+ footerSpans.add(Span.styled(" o ", Theme.hintKey()));
+ footerSpans.add(Span.raw(" options "));
+ }
+ Title footer = Title.from(Line.from(footerSpans));
Block block = Block.builder()
.borderType(BorderType.ROUNDED).borders(Borders.ALL)
.title(title)
+ .titleBottom(footer)
.build();
if (docLines != null) {
- frame.renderWidget(block, popup);
- Rect inner = block.inner(popup);
- int visibleLines = inner.height();
+ frame.renderWidget(block, area);
+ Rect inner = block.inner(area);
+ List<Rect> hChunks = Layout.horizontal()
+ .constraints(Constraint.fill(), Constraint.length(1))
+ .split(inner);
+ int visibleLines = hChunks.get(0).height();
int totalLines = docLines.size();
int clampedScroll = Math.min(docScroll, Math.max(0, totalLines -
visibleLines));
+ docScroll = clampedScroll;
int end = Math.min(clampedScroll + visibleLines, totalLines);
List<Line> visible = new
ArrayList<>(docLines.subList(clampedScroll, end));
while (visible.size() < visibleLines) {
@@ -427,15 +531,46 @@ class DocViewerPopup {
frame.renderWidget(
Paragraph.builder().text(Text.from(visible.toArray(Line[]::new)))
.overflow(Overflow.CLIP).build(),
- inner);
+ hChunks.get(0));
+ if (totalLines > visibleLines) {
+ scrollbarState
+ .contentLength(totalLines)
+ .viewportContentLength(visibleLines)
+ .position(clampedScroll);
+ frame.renderStatefulWidget(Scrollbar.builder().build(),
hChunks.get(1), scrollbarState);
+ }
} else {
+ frame.renderWidget(block, area);
+ Rect inner = block.inner(area);
+ List<Rect> hChunks = Layout.horizontal()
+ .constraints(Constraint.fill(), Constraint.length(1))
+ .split(inner);
+ lastContentWidth = hChunks.get(0).width();
+ int viewportHeight = hChunks.get(0).height();
+ if (lastTotalHeight > 0) {
+ int maxScroll = Math.max(0, lastTotalHeight - viewportHeight);
+ if (docScroll > maxScroll) {
+ docScroll = maxScroll;
+ }
+ }
MarkdownView view = MarkdownView.builder()
.source(docContent)
.scroll(docScroll)
- .block(block)
.styles(Theme.markdownStyles())
.build();
- frame.renderWidget(view, popup);
+ frame.renderWidget(view, hChunks.get(0));
+ int totalHeight = view.computeHeight(lastContentWidth);
+ lastTotalHeight = totalHeight;
+ if (totalHeight > viewportHeight) {
+ scrollbarState
+ .contentLength(totalHeight)
+ .viewportContentLength(viewportHeight)
+ .position(docScroll);
+ frame.renderStatefulWidget(Scrollbar.builder().build(),
hChunks.get(1), scrollbarState);
+ }
+ }
+ if (tocPopup.isVisible()) {
+ tocPopup.render(frame, area);
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
index e298e7664358..78a42873521e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
@@ -22,6 +22,7 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import dev.tamboui.tui.TuiRunner;
+import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
/**
@@ -41,6 +42,14 @@ class MonitorContext {
boolean logPinVisible;
boolean ratePerMinute;
BiConsumer<String, Boolean> notificationCallback;
+ BiConsumer<String, String> openMarkdownCallback;
+ OpenOptionsCallback openOptionsCallback;
+ OpenOptionsCallback openCatalogDocCallback;
+
+ @FunctionalInterface
+ interface OpenOptionsCallback {
+ void accept(String name, String kind, CamelCatalog catalog);
+ }
MonitorContext(
AtomicReference<List<IntegrationInfo>> data,
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OptionsViewerPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OptionsViewerPopup.java
new file mode 100644
index 000000000000..779232cf7ec8
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OptionsViewerPopup.java
@@ -0,0 +1,462 @@
+/*
+ * 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 java.util.Set;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Overflow;
+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;
+import dev.tamboui.tui.event.MouseEventKind;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.scrollbar.Scrollbar;
+import dev.tamboui.widgets.scrollbar.ScrollbarState;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.tooling.model.BaseOptionModel;
+import org.apache.camel.tooling.model.ComponentModel;
+import org.apache.camel.tooling.model.DataFormatModel;
+import org.apache.camel.tooling.model.EipModel;
+import org.apache.camel.tooling.model.LanguageModel;
+import org.apache.camel.tooling.model.OtherModel;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint;
+import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hintLast;
+
+class OptionsViewerPopup {
+
+ private boolean visible;
+ private String title;
+ private String currentKind;
+ private CamelCatalog currentCatalog;
+ private String[] tabLabels;
+ private List<List<Line>> tabContent;
+ private int activeTab;
+ private int[] tabScroll;
+ private boolean wantsDoc;
+
+ private final ScrollbarState scrollbarState = new ScrollbarState();
+
+ boolean isVisible() {
+ return visible;
+ }
+
+ boolean consumeWantsDoc() {
+ boolean v = wantsDoc;
+ wantsDoc = false;
+ return v;
+ }
+
+ String getCurrentName() {
+ return title;
+ }
+
+ String getCurrentKind() {
+ return currentKind;
+ }
+
+ CamelCatalog getCurrentCatalog() {
+ return currentCatalog;
+ }
+
+ void close() {
+ visible = false;
+ }
+
+ void open(String name, String kind, CamelCatalog catalog) {
+ if (catalog == null) {
+ return;
+ }
+ this.title = name;
+ this.currentKind = kind;
+ this.currentCatalog = catalog;
+ this.activeTab = 0;
+ this.wantsDoc = false;
+
+ switch (kind) {
+ case "component" -> openComponent(name, catalog);
+ case "dataformat" -> openDataFormat(name, catalog);
+ case "language" -> openLanguage(name, catalog);
+ case "eip" -> openEip(name, catalog);
+ default -> openOther(name, catalog);
+ }
+
+ if (tabContent == null || tabContent.isEmpty()) {
+ visible = false;
+ return;
+ }
+ tabScroll = new int[tabLabels.length];
+ visible = true;
+ }
+
+ private void openComponent(String name, CamelCatalog catalog) {
+ ComponentModel model = catalog.componentModel(name);
+ if (model == null) {
+ return;
+ }
+ tabLabels = new String[] { "Component", "Endpoint", "Headers" };
+ tabContent = new ArrayList<>();
+ tabContent.add(buildOptionLines(model.getComponentOptions()));
+ tabContent.add(buildOptionLines(model.getEndpointOptions()));
+ tabContent.add(buildHeaderLines(model.getEndpointHeaders()));
+ }
+
+ private void openDataFormat(String name, CamelCatalog catalog) {
+ DataFormatModel model = catalog.dataFormatModel(name);
+ if (model == null) {
+ return;
+ }
+ tabLabels = new String[] { "Options" };
+ tabContent = new ArrayList<>();
+ tabContent.add(buildOptionLines(model.getOptions()));
+ }
+
+ private void openLanguage(String name, CamelCatalog catalog) {
+ LanguageModel model = catalog.languageModel(name);
+ if (model == null) {
+ return;
+ }
+ tabLabels = new String[] { "Options" };
+ tabContent = new ArrayList<>();
+ tabContent.add(buildOptionLines(model.getOptions()));
+ }
+
+ private static final Set<String> EIP_SKIP_OPTIONS = Set.of("input",
"output", "outputs");
+
+ private void openEip(String name, CamelCatalog catalog) {
+ EipModel model = catalog.eipModel(name);
+ if (model == null) {
+ return;
+ }
+ List<? extends BaseOptionModel> filtered = model.getOptions().stream()
+ .filter(o -> !EIP_SKIP_OPTIONS.contains(o.getName()))
+ .toList();
+ List<Line> optLines = buildOptionLines(filtered);
+ List<Line> propLines = buildOptionLines(model.getExchangeProperties());
+ if (!propLines.isEmpty()) {
+ tabLabels = new String[] { "Options", "Properties" };
+ tabContent = new ArrayList<>();
+ tabContent.add(optLines);
+ tabContent.add(propLines);
+ } else {
+ tabLabels = new String[] { "Options" };
+ tabContent = new ArrayList<>();
+ tabContent.add(optLines);
+ }
+ }
+
+ private void openOther(String name, CamelCatalog catalog) {
+ OtherModel model = catalog.otherModel(name);
+ if (model == null) {
+ return;
+ }
+ tabLabels = new String[] { "Options" };
+ tabContent = new ArrayList<>();
+ tabContent.add(buildOptionLines(model.getOptions()));
+ }
+
+ // --- Option line building ---
+
+ private List<Line> buildOptionLines(List<? extends BaseOptionModel>
options) {
+ List<Line> lines = new ArrayList<>();
+ if (options == null || options.isEmpty()) {
+ lines.add(Line.from(Span.styled(" No options",
Style.EMPTY.dim())));
+ return lines;
+ }
+ for (int i = 0; i < options.size(); i++) {
+ if (i > 0) {
+ lines.add(Line.from(""));
+ }
+ BaseOptionModel opt = options.get(i);
+ buildSingleOption(lines, opt);
+ }
+ return lines;
+ }
+
+ private void buildSingleOption(List<Line> lines, BaseOptionModel opt) {
+ List<Span> header = new ArrayList<>();
+ header.add(Span.styled(" " + opt.getName(),
Style.EMPTY.fg(Theme.accent())));
+
+ String type = opt.getShortJavaType();
+ if (type != null) {
+ header.add(Span.styled(" (" + type + ")", Style.EMPTY.dim()));
+ }
+
+ if (opt.isRequired()) {
+ header.add(Span.styled(" [required]", Theme.warning()));
+ }
+ if (opt.isDeprecated()) {
+ header.add(Span.styled(" [deprecated]", Theme.error()));
+ }
+ if (opt.isSecret()) {
+ header.add(Span.styled(" [secret]",
Style.EMPTY.fg(Theme.accent()).dim()));
+ }
+
+ lines.add(Line.from(header));
+
+ Object dv = opt.getDefaultValue();
+ if (dv != null) {
+ String dvStr = dv.toString();
+ if (!dvStr.isEmpty()) {
+ lines.add(Line.from(
+ Span.styled(" Default: ", Style.EMPTY.dim()),
+ Span.raw(dvStr)));
+ }
+ }
+
+ List<String> enums = opt.getEnums();
+ if (enums != null && !enums.isEmpty()) {
+ String enumStr = String.join(", ", enums);
+ lines.add(Line.from(
+ Span.styled(" Enum: ", Style.EMPTY.dim()),
+ Span.raw(enumStr)));
+ }
+
+ String group = opt.getGroup();
+ if (group != null && !group.isEmpty()) {
+ lines.add(Line.from(
+ Span.styled(" Group: ", Style.EMPTY.dim()),
+ Span.raw(group)));
+ }
+
+ String desc = opt.getDescription();
+ if (desc != null && !desc.isEmpty()) {
+ lines.add(Line.from(
+ Span.styled(" ", Style.EMPTY),
+ Span.styled(desc, Style.EMPTY.dim())));
+ }
+ }
+
+ private List<Line>
buildHeaderLines(List<ComponentModel.EndpointHeaderModel> headers) {
+ List<Line> lines = new ArrayList<>();
+ if (headers == null || headers.isEmpty()) {
+ lines.add(Line.from(Span.styled(" No headers",
Style.EMPTY.dim())));
+ return lines;
+ }
+ for (int i = 0; i < headers.size(); i++) {
+ if (i > 0) {
+ lines.add(Line.from(""));
+ }
+ ComponentModel.EndpointHeaderModel h = headers.get(i);
+ buildSingleHeader(lines, h);
+ }
+ return lines;
+ }
+
+ private void buildSingleHeader(List<Line> lines,
ComponentModel.EndpointHeaderModel h) {
+ List<Span> header = new ArrayList<>();
+ header.add(Span.styled(" " + h.getName(),
Style.EMPTY.fg(Theme.accent())));
+
+ String type = h.getShortJavaType();
+ if (type != null) {
+ header.add(Span.styled(" (" + type + ")", Style.EMPTY.dim()));
+ }
+
+ String group = h.getGroup();
+ if (group != null && !group.isEmpty()) {
+ header.add(Span.styled(" [" + group + "]", Theme.info()));
+ }
+
+ lines.add(Line.from(header));
+
+ String constant = h.getConstantName();
+ if (constant != null && !constant.isEmpty()) {
+ lines.add(Line.from(
+ Span.styled(" Constant: ", Style.EMPTY.dim()),
+ Span.raw(constant)));
+ }
+
+ String desc = h.getDescription();
+ if (desc != null && !desc.isEmpty()) {
+ lines.add(Line.from(
+ Span.styled(" ", Style.EMPTY),
+ Span.styled(desc, Style.EMPTY.dim())));
+ }
+ }
+
+ // --- Key handling ---
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ if (!visible) {
+ return false;
+ }
+ if (ke.isCancel()) {
+ visible = false;
+ return true;
+ }
+ if (ke.isCharIgnoreCase('d')) {
+ wantsDoc = true;
+ visible = false;
+ return true;
+ }
+ if (ke.isUp() || ke.isChar('k')) {
+ tabScroll[activeTab] = Math.max(0, tabScroll[activeTab] - 1);
+ return true;
+ }
+ if (ke.isDown() || ke.isChar('j')) {
+ tabScroll[activeTab]++;
+ return true;
+ }
+ if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+ tabScroll[activeTab] = Math.max(0, tabScroll[activeTab] - 10);
+ return true;
+ }
+ if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+ tabScroll[activeTab] += 10;
+ return true;
+ }
+ if (ke.isHome() || ke.isKey(KeyCode.HOME)) {
+ tabScroll[activeTab] = 0;
+ return true;
+ }
+ if (ke.isEnd() || ke.isKey(KeyCode.END)) {
+ tabScroll[activeTab] = Integer.MAX_VALUE;
+ return true;
+ }
+ if (tabLabels.length > 1) {
+ if (ke.isKey(KeyCode.TAB) || ke.isRight()) {
+ activeTab = (activeTab + 1) % tabLabels.length;
+ return true;
+ }
+ if (ke.isLeft()) {
+ activeTab = (activeTab - 1 + tabLabels.length) %
tabLabels.length;
+ return true;
+ }
+ }
+ return true;
+ }
+
+ boolean handleMouseEvent(MouseEvent me) {
+ if (!visible) {
+ return false;
+ }
+ if (me.kind() == MouseEventKind.SCROLL_UP) {
+ tabScroll[activeTab] = Math.max(0, tabScroll[activeTab] - 3);
+ return true;
+ }
+ if (me.kind() == MouseEventKind.SCROLL_DOWN) {
+ tabScroll[activeTab] += 3;
+ return true;
+ }
+ return true;
+ }
+
+ // --- Rendering ---
+
+ void render(Frame frame, Rect area) {
+ if (!visible) {
+ return;
+ }
+
+ frame.renderWidget(Clear.INSTANCE, area);
+
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+ .title(buildTitle())
+ .titleBottom(buildFooter())
+ .build();
+ frame.renderWidget(block, area);
+
+ Rect inner = block.inner(area);
+ List<Rect> hChunks = Layout.horizontal()
+ .constraints(Constraint.fill(), Constraint.length(1))
+ .split(inner);
+
+ List<Line> content = tabContent.get(activeTab);
+ int visibleLines = hChunks.get(0).height();
+ int totalLines = content.size();
+ int clampedScroll = Math.min(tabScroll[activeTab], Math.max(0,
totalLines - visibleLines));
+ tabScroll[activeTab] = clampedScroll;
+ int end = Math.min(clampedScroll + visibleLines, totalLines);
+
+ List<Line> visible = new ArrayList<>(content.subList(clampedScroll,
end));
+ while (visible.size() < visibleLines) {
+ visible.add(Line.from(""));
+ }
+
+ frame.renderWidget(
+
Paragraph.builder().text(Text.from(visible.toArray(Line[]::new)))
+ .overflow(Overflow.CLIP).build(),
+ hChunks.get(0));
+
+ if (totalLines > visibleLines) {
+ scrollbarState
+ .contentLength(totalLines)
+ .viewportContentLength(visibleLines)
+ .position(clampedScroll);
+ frame.renderStatefulWidget(Scrollbar.builder().build(),
hChunks.get(1), scrollbarState);
+ }
+ }
+
+ private Title buildTitle() {
+ List<Span> spans = new ArrayList<>();
+ spans.add(Span.raw(" " + title + " Options "));
+
+ for (int i = 0; i < tabLabels.length; i++) {
+ if (i > 0) {
+ spans.add(Span.styled(" │ ", Style.EMPTY.dim()));
+ }
+ if (i == activeTab) {
+ spans.add(Span.styled(tabLabels[i], Style.EMPTY.bold()));
+ } else {
+ spans.add(Span.styled(tabLabels[i], Style.EMPTY.dim()));
+ }
+ }
+ spans.add(Span.raw(" "));
+
+ return Title.from(Line.from(spans));
+ }
+
+ private Title buildFooter() {
+ List<Span> spans = new ArrayList<>();
+ spans.add(Span.styled(" Esc ", Theme.hintKey()));
+ spans.add(Span.raw(" back "));
+ spans.add(Span.styled(" ↑↓ ", Theme.hintKey()));
+ spans.add(Span.raw(" scroll "));
+ if (tabLabels != null && tabLabels.length > 1) {
+ spans.add(Span.styled(" ←→ ", Theme.hintKey()));
+ spans.add(Span.raw(" tab "));
+ }
+ spans.add(Span.styled(" d ", Theme.hintKey()));
+ spans.add(Span.raw(" doc "));
+ return Title.from(Line.from(spans));
+ }
+
+ void renderFooter(List<Span> spans) {
+ hint(spans, "Esc", "back");
+ hint(spans, "↑↓", "scroll");
+ if (tabLabels != null && tabLabels.length > 1) {
+ hint(spans, "←→", "tab");
+ }
+ hintLast(spans, "d", "doc");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopup.java
new file mode 100644
index 000000000000..593092ef638f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TocPopup.java
@@ -0,0 +1,288 @@
+/*
+ * 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.tui.event.MouseEvent;
+import dev.tamboui.tui.event.MouseEventKind;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+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.scrollbar.Scrollbar;
+import dev.tamboui.widgets.scrollbar.ScrollbarState;
+
+class TocPopup {
+
+ record TocEntry(int level, String title, int charOffset) {
+ }
+
+ private boolean visible;
+ private final FuzzyFilter filter = new FuzzyFilter();
+ private final ListState listState = new ListState();
+ private final ScrollbarState scrollbarState = new ScrollbarState();
+ private List<TocEntry> allEntries;
+ private List<TocEntry> filteredEntries;
+ private Rect popupRect;
+ private TocEntry pendingEntry;
+
+ boolean isVisible() {
+ return visible;
+ }
+
+ void open(List<TocEntry> entries) {
+ this.allEntries = entries;
+ this.pendingEntry = null;
+ this.visible = true;
+ filter.clearFilter();
+ rebuildList();
+ }
+
+ void close() {
+ visible = false;
+ filter.clearFilter();
+ }
+
+ TocEntry consumePendingEntry() {
+ TocEntry e = pendingEntry;
+ pendingEntry = null;
+ return e;
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ int size = filteredEntries != null ? filteredEntries.size() : 0;
+ if (ke.isCancel()) {
+ close();
+ return true;
+ }
+ if (ke.isUp()) {
+ listState.selectPrevious();
+ return true;
+ }
+ if (ke.isDown()) {
+ listState.selectNext(size);
+ return true;
+ }
+ if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+ for (int i = 0; i < 5; i++) {
+ listState.selectPrevious();
+ }
+ return true;
+ }
+ if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+ for (int i = 0; i < 5; i++) {
+ listState.selectNext(size);
+ }
+ return true;
+ }
+ if (ke.isHome() || ke.isKey(KeyCode.HOME)) {
+ listState.selectFirst();
+ return true;
+ }
+ if (ke.isEnd() || ke.isKey(KeyCode.END)) {
+ listState.selectLast(size);
+ return true;
+ }
+ if (ke.isConfirm()) {
+ Integer sel = listState.selected();
+ if (sel != null && filteredEntries != null && sel <
filteredEntries.size()) {
+ pendingEntry = filteredEntries.get(sel);
+ close();
+ }
+ return true;
+ }
+ if (ke.isKey(KeyCode.BACKSPACE)) {
+ filter.deleteChar();
+ rebuildList();
+ return true;
+ }
+ if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
+ filter.appendChar(ke.string().charAt(0));
+ rebuildList();
+ return true;
+ }
+ return true;
+ }
+
+ boolean handleMouseEvent(MouseEvent me) {
+ if (me.kind() == MouseEventKind.SCROLL_UP) {
+ listState.selectPrevious();
+ return true;
+ }
+ if (me.kind() == MouseEventKind.SCROLL_DOWN) {
+ int size = filteredEntries != null ? filteredEntries.size() : 0;
+ listState.selectNext(size);
+ return true;
+ }
+ if (me.isClick()) {
+ if (popupRect != null && popupRect.contains(me.x(), me.y())) {
+ int idx = TuiHelper.listItemAt(popupRect, 0,
+ (filteredEntries != null ? filteredEntries.size() : 0)
+ 2,
+ me.x(), me.y());
+ if (idx >= 2 && filteredEntries != null && idx - 2 <
filteredEntries.size()) {
+ listState.select(idx - 2);
+ handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER));
+ }
+ return true;
+ }
+ close();
+ return true;
+ }
+ return true;
+ }
+
+ void render(Frame frame, Rect area) {
+ if (filteredEntries == null) {
+ return;
+ }
+ int popupW = Math.min(80, area.width() - 4);
+ int contentH = filteredEntries.size() + 2;
+ int maxH = area.height() - 4;
+ int popupH = contentH + 2 <= maxH ? contentH + 2 : Math.min(contentH +
2, maxH);
+ int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
+ int y = area.top() + 2;
+ Rect popup = new Rect(x, y, Math.min(popupW, area.width()),
Math.min(popupH, area.height() - 2));
+ this.popupRect = popup;
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+
+ String filterText = filter.hasFilter() ? filter.filter() : "";
+ String prompt = "> " + filterText + "█";
+
+ List<ListItem> items = new ArrayList<>();
+ items.add(ListItem.from(Line.from(Span.styled(prompt, Theme.info()))));
+ String sep = "─".repeat(Math.max(1, popupW - 2));
+ items.add(ListItem.from(Line.from(Span.styled(sep,
Style.EMPTY.dim()))));
+
+ Style normalStyle = Style.EMPTY;
+ Style matchStyle = Theme.label().bold();
+ Style dimStyle = Style.EMPTY.dim();
+ int titleColWidth = popupW - 8;
+
+ for (TocEntry entry : filteredEntries) {
+ List<Span> spans = new ArrayList<>();
+ String indent = entry.level() >= 3 ? " " : " ";
+ String prefix = entry.level() >= 3 ? "└ " : "";
+ String title = prefix + entry.title();
+ if (title.length() > titleColWidth) {
+ title = title.substring(0, titleColWidth - 1) + "…";
+ }
+
+ spans.add(Span.raw(indent));
+ if (filter.hasFilter()) {
+ int[] nameMatch = FuzzyFilter.fuzzyMatch(title,
filter.filter());
+ if (nameMatch != null) {
+ Line hl = FuzzyFilter.highlightLine(title, nameMatch,
normalStyle, matchStyle);
+ spans.addAll(hl.spans());
+ } else {
+ spans.add(Span.styled(title, normalStyle));
+ }
+ } else {
+ spans.add(Span.styled(title, normalStyle));
+ }
+ items.add(ListItem.from(Line.from(spans)));
+ }
+
+ ListState renderState = new ListState();
+ Integer sel = listState.selected();
+ if (sel != null) {
+ renderState.select(sel + 2);
+ }
+
+ int total = allEntries != null ? allEntries.size() : 0;
+ int shown = filteredEntries.size();
+ String blockTitle = shown == total
+ ? " Table of Contents (" + total + ") "
+ : " Table of Contents (" + shown + "/" + total + ") ";
+
+ ListWidget list = ListWidget.builder()
+ .items(items.toArray(ListItem[]::new))
+ .highlightStyle(Theme.selectionBg())
+ .highlightSymbol("")
+ .scrollMode(ScrollMode.AUTO_SCROLL)
+ .block(Block.builder()
+ .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+ .title(blockTitle)
+ .build())
+ .build();
+ frame.renderStatefulWidget(list, popup, renderState);
+
+ int visibleRows = Math.max(1, popup.height() - 2);
+ if (shown + 2 > visibleRows) {
+ scrollbarState
+ .contentLength(shown)
+ .viewportContentLength(visibleRows)
+ .position(sel != null ? sel : 0);
+ frame.renderStatefulWidget(Scrollbar.builder().build(), popup,
scrollbarState);
+ }
+ }
+
+ private void rebuildList() {
+ if (allEntries == null) {
+ filteredEntries = List.of();
+ return;
+ }
+ if (!filter.hasFilter()) {
+ filteredEntries = new ArrayList<>(allEntries);
+ } else {
+ filteredEntries = new ArrayList<>();
+ String f = filter.filter();
+ for (TocEntry entry : allEntries) {
+ boolean match;
+ if (f.length() <= 2) {
+ match = entry.title().toLowerCase().contains(f);
+ } else {
+ match = filter.match(entry.title()) != null;
+ }
+ if (match) {
+ filteredEntries.add(entry);
+ }
+ }
+ }
+ listState.select(filteredEntries.isEmpty() ? null : 0);
+ }
+
+ static List<TocEntry> extractHeadings(String markdown) {
+ List<TocEntry> entries = new ArrayList<>();
+ if (markdown == null) {
+ return entries;
+ }
+ int offset = 0;
+ for (String line : markdown.split("\n", -1)) {
+ if (line.startsWith("### ")) {
+ entries.add(new TocEntry(3, line.substring(4).trim(), offset));
+ } else if (line.startsWith("## ")) {
+ entries.add(new TocEntry(2, line.substring(3).trim(), offset));
+ }
+ offset += line.length() + 1;
+ }
+ return entries;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 160afbae5523..981ac876bbd5 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -32,6 +32,7 @@ import org.apache.camel.tooling.model.BaseModel;
import org.apache.camel.tooling.model.BaseOptionModel;
import org.apache.camel.tooling.model.ComponentModel;
import org.apache.camel.tooling.model.DataFormatModel;
+import org.apache.camel.tooling.model.EipModel;
import org.apache.camel.tooling.model.LanguageModel;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -553,19 +554,23 @@ class TuiToolRegistry {
tools.add(toToolDef(toolDef(
"tui_catalog_doc",
- "Get documentation for a Camel catalog artifact (component,
data format, language) "
+ "Get documentation for a Camel catalog artifact (component,
data format, language, EIP) "
+ "including description, options, and
Maven coordinates. "
+ "Use optionsFilter to search options by
keyword (e.g., 'security', 'ssl', 'timeout'). "
+ "This enables queries like 'what options
are there on kafka about security'. "
+ + "Set includeDoc=true to get the full
AsciiDoc documentation for deep-dive questions. "
+ "Uses the Camel version from the selected
integration.",
Map.of("name", propDef("string",
- "Artifact name (e.g., kafka, json-jackson, simple,
timer)"),
+ "Artifact name (e.g., kafka, json-jackson, simple,
timer, choice, split)"),
"kind", propDef("string",
- "Artifact kind: component, dataformat, or
language. "
- + "If omitted, auto-detects
by trying component first, then dataformat, then language."),
+ "Artifact kind: component, dataformat,
language, or eip. "
+ + "If omitted, auto-detects
by trying component first, then dataformat, then language, then eip."),
"includeOptions", propDef("boolean",
"Whether to include configuration options in
the response (default: true). "
+ "Set to false
for a lightweight response with just metadata."),
+ "includeDoc", propDef("boolean",
+ "Whether to include the full AsciiDoc
documentation text in the response (default: false). "
+ + "Useful for
deep-dive questions about usage, examples, and configuration patterns."),
"optionsFilter", propDef("string",
"Filter options by keyword in name or
description (case-insensitive substring match). "
+ "Only used when
includeOptions is true.")),
@@ -1622,6 +1627,7 @@ class TuiToolRegistry {
String kind = args.get("kind") instanceof String v ? v : null;
String optionsFilter = args.get("optionsFilter") instanceof String v ?
v : null;
boolean includeOptions =
!Boolean.FALSE.equals(args.get("includeOptions"));
+ boolean includeDoc = Boolean.TRUE.equals(args.get("includeDoc"));
String version = facade.getSelectedCamelVersion();
try {
@@ -1629,7 +1635,7 @@ class TuiToolRegistry {
if (catalog == null) {
return "{\"error\": \"Could not load catalog" + (version !=
null ? " for version " + version : "") + "\"}";
}
- return buildCatalogDocResult(catalog, name, kind, optionsFilter,
includeOptions);
+ return buildCatalogDocResult(catalog, name, kind, optionsFilter,
includeOptions, includeDoc);
} catch (Exception e) {
JsonObject err = new JsonObject();
err.put("error", "Failed to load catalog: " + e.getMessage());
@@ -1638,13 +1644,15 @@ class TuiToolRegistry {
}
private String buildCatalogDocResult(
- CamelCatalog catalog, String name, String kind, String
optionsFilter, boolean includeOptions) {
+ CamelCatalog catalog, String name, String kind, String
optionsFilter,
+ boolean includeOptions, boolean includeDoc) {
String lowerFilter = optionsFilter != null ?
optionsFilter.toLowerCase() : null;
if (kind == null || "component".equals(kind)) {
ComponentModel cm = catalog.componentModel(name);
if (cm != null) {
- return buildComponentDocJson(cm, lowerFilter, includeOptions);
+ String doc = includeDoc ? catalog.asciiDoc(name +
"-component") : null;
+ return buildComponentDocJson(cm, lowerFilter, includeOptions,
doc);
}
if (kind != null) {
return "{\"error\": \"Component not found: " + name + "\"}";
@@ -1653,7 +1661,8 @@ class TuiToolRegistry {
if (kind == null || "dataformat".equals(kind)) {
DataFormatModel dm = catalog.dataFormatModel(name);
if (dm != null) {
- return buildDataFormatDocJson(dm, lowerFilter, includeOptions);
+ String doc = includeDoc ? catalog.asciiDoc(name +
"-dataformat") : null;
+ return buildDataFormatDocJson(dm, lowerFilter, includeOptions,
doc);
}
if (kind != null) {
return "{\"error\": \"Data format not found: " + name + "\"}";
@@ -1662,12 +1671,23 @@ class TuiToolRegistry {
if (kind == null || "language".equals(kind)) {
LanguageModel lm = catalog.languageModel(name);
if (lm != null) {
- return buildLanguageDocJson(lm, lowerFilter, includeOptions);
+ String doc = includeDoc ? catalog.asciiDoc(name + "-language")
: null;
+ return buildLanguageDocJson(lm, lowerFilter, includeOptions,
doc);
}
if (kind != null) {
return "{\"error\": \"Language not found: " + name + "\"}";
}
}
+ if (kind == null || "eip".equals(kind)) {
+ EipModel em = catalog.eipModel(name);
+ if (em != null) {
+ String doc = includeDoc ? catalog.asciiDoc(name + "-eip") :
null;
+ return buildEipDocJson(em, lowerFilter, includeOptions, doc);
+ }
+ if (kind != null) {
+ return "{\"error\": \"EIP not found: " + name + "\"}";
+ }
+ }
return "{\"error\": \"Artifact not found: " + name + "\"}";
}
@@ -1693,7 +1713,7 @@ class TuiToolRegistry {
}
}
- private String buildComponentDocJson(ComponentModel model, String filter,
boolean includeOptions) {
+ private String buildComponentDocJson(ComponentModel model, String filter,
boolean includeOptions, String doc) {
JsonObject result = new JsonObject();
result.put("kind", "component");
result.put("name", model.getScheme());
@@ -1731,10 +1751,13 @@ class TuiToolRegistry {
result.put("options", options);
result.put("matchedOptions", options.size());
}
+ if (doc != null) {
+ result.put("doc", doc);
+ }
return Jsoner.serialize(result);
}
- private String buildDataFormatDocJson(DataFormatModel model, String
filter, boolean includeOptions) {
+ private String buildDataFormatDocJson(DataFormatModel model, String
filter, boolean includeOptions, String doc) {
JsonObject result = new JsonObject();
result.put("kind", "dataformat");
result.put("name", model.getName());
@@ -1759,10 +1782,13 @@ class TuiToolRegistry {
result.put("options", options);
result.put("matchedOptions", options.size());
}
+ if (doc != null) {
+ result.put("doc", doc);
+ }
return Jsoner.serialize(result);
}
- private String buildLanguageDocJson(LanguageModel model, String filter,
boolean includeOptions) {
+ private String buildLanguageDocJson(LanguageModel model, String filter,
boolean includeOptions, String doc) {
JsonObject result = new JsonObject();
result.put("kind", "language");
result.put("name", model.getName());
@@ -1787,6 +1813,40 @@ class TuiToolRegistry {
result.put("options", options);
result.put("matchedOptions", options.size());
}
+ if (doc != null) {
+ result.put("doc", doc);
+ }
+ return Jsoner.serialize(result);
+ }
+
+ private String buildEipDocJson(EipModel model, String filter, boolean
includeOptions, String doc) {
+ JsonObject result = new JsonObject();
+ result.put("kind", "eip");
+ result.put("name", model.getName());
+ result.put("title", model.getTitle());
+ result.put("description", model.getDescription());
+ if (model.getLabel() != null) {
+ result.put("label", model.getLabel());
+ }
+ result.put("input", model.isInput());
+ result.put("output", model.isOutput());
+ addCommonModelFields(result, model);
+
+ if (includeOptions) {
+ JsonArray options = new JsonArray();
+ if (model.getOptions() != null) {
+ for (BaseOptionModel opt : model.getOptions()) {
+ if (matchesOptionFilter(opt, filter)) {
+ options.add(optionToJson(opt, null));
+ }
+ }
+ }
+ result.put("options", options);
+ result.put("matchedOptions", options.size());
+ }
+ if (doc != null) {
+ result.put("doc", doc);
+ }
return Jsoner.serialize(result);
}