diff --git a/aesh/src/main/java/org/aesh/command/impl/completer/CommandSuggestionProvider.java b/aesh/src/main/java/org/aesh/command/impl/completer/CommandSuggestionProvider.java
new file mode 100644
index 00000000..102127b9
--- /dev/null
+++ b/aesh/src/main/java/org/aesh/command/impl/completer/CommandSuggestionProvider.java
@@ -0,0 +1,177 @@
+package org.aesh.command.impl.completer;
+
+import java.util.List;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import org.aesh.command.CommandNotFoundException;
+import org.aesh.command.container.CommandContainer;
+import org.aesh.command.impl.internal.ProcessedCommand;
+import org.aesh.command.impl.internal.ProcessedOption;
+import org.aesh.command.impl.parser.CommandLineParser;
+import org.aesh.command.invocation.CommandInvocation;
+import org.aesh.command.registry.CommandRegistry;
+import org.aesh.readline.SuggestionProvider;
+import org.aesh.terminal.utils.LoggerUtil;
+
+/**
+ * Suggests command names, subcommand names, and option names from a {@link CommandRegistry}.
+ *
+ * Only suggests when there is exactly one unambiguous match.
+ */
+public class CommandSuggestionProvider implements SuggestionProvider {
+
+ private static final Logger LOGGER = LoggerUtil.getLogger(CommandSuggestionProvider.class.getName());
+
+ private final CommandRegistry registry;
+
+ public CommandSuggestionProvider(CommandRegistry registry) {
+ this.registry = registry;
+ }
+
+ @Override
+ public String suggest(String buffer) {
+ if (buffer == null || buffer.isEmpty()) {
+ return null;
+ }
+
+ String trimmed = buffer.trim();
+ if (trimmed.isEmpty()) {
+ return null;
+ }
+
+ // Check if the user is typing an option (--something)
+ int lastSpace = buffer.lastIndexOf(' ');
+ if (lastSpace >= 0) {
+ String lastWord = buffer.substring(lastSpace + 1);
+ if (lastWord.startsWith("--")) {
+ return suggestOption(trimmed, lastWord);
+ }
+ }
+
+ // Check if buffer contains spaces -> could be subcommand
+ if (trimmed.contains(" ")) {
+ return suggestSubcommand(trimmed);
+ }
+
+ // Single word -> suggest command name
+ return suggestCommand(trimmed, buffer);
+ }
+
+ private String suggestCommand(String prefix, String originalBuffer) {
+ Set allNames = registry.getAllCommandNames();
+ String match = null;
+ for (String name : allNames) {
+ if (name.startsWith(prefix) && !name.equals(prefix)) {
+ if (!isCommandActivated(name)) {
+ continue;
+ }
+ if (match != null) {
+ // Ambiguous - more than one match
+ return null;
+ }
+ match = name;
+ }
+ }
+ if (match != null) {
+ return match.substring(prefix.length());
+ }
+ return null;
+ }
+
+ private String suggestSubcommand(String trimmed) {
+ // Split into command name and the rest
+ int firstSpace = trimmed.indexOf(' ');
+ String commandName = trimmed.substring(0, firstSpace);
+ String rest = trimmed.substring(firstSpace + 1).trim();
+
+ if (rest.isEmpty()) {
+ return null;
+ }
+
+ try {
+ CommandContainer container = registry.getCommand(commandName, trimmed);
+ CommandLineParser parser = container.getParser();
+
+ if (parser.isGroupCommand()) {
+ // rest is a partial subcommand name
+ List> childParsers = parser.getAllChildParsers();
+ String match = null;
+ for (CommandLineParser child : childParsers) {
+ String childName = child.getProcessedCommand().name();
+ if (childName.startsWith(rest) && !childName.equals(rest)) {
+ if (match != null) {
+ return null; // ambiguous
+ }
+ match = childName;
+ }
+ }
+ if (match != null) {
+ return match.substring(rest.length());
+ }
+ }
+ } catch (CommandNotFoundException e) {
+ // Command not found, no suggestion
+ } catch (Exception e) {
+ LOGGER.log(Level.FINE, "Error suggesting subcommand", e);
+ }
+ return null;
+ }
+
+ private String suggestOption(String trimmed, String lastWord) {
+ // Extract command name from the full buffer
+ String[] parts = trimmed.split("\\s+");
+ if (parts.length < 1) {
+ return null;
+ }
+
+ String commandName = parts[0];
+ String prefix = lastWord.substring(2); // strip --
+
+ try {
+ CommandContainer container = registry.getCommand(commandName, trimmed);
+ CommandLineParser parser = container.getParser();
+
+ ProcessedCommand, ?> processedCommand;
+
+ // For group commands, check if we have a subcommand
+ if (parser.isGroupCommand() && parts.length >= 2 && !parts[1].startsWith("-")) {
+ CommandLineParser childParser = parser.getChildParser(parts[1]);
+ if (childParser != null) {
+ processedCommand = childParser.getProcessedCommand();
+ } else {
+ processedCommand = parser.getProcessedCommand();
+ }
+ } else {
+ processedCommand = parser.getProcessedCommand();
+ }
+
+ List possibleNames = processedCommand.findPossibleLongNames(prefix);
+ if (possibleNames.size() == 1) {
+ String optionName = possibleNames.get(0);
+ String suffix = optionName.substring(prefix.length());
+ // Append = if the option takes a value
+ ProcessedOption option = processedCommand.findLongOptionNoActivatorCheck(optionName);
+ if (option != null && option.hasValue()) {
+ suffix += "=";
+ }
+ return suffix;
+ }
+ } catch (CommandNotFoundException e) {
+ // Command not found, no suggestion
+ } catch (Exception e) {
+ LOGGER.log(Level.FINE, "Error suggesting option", e);
+ }
+ return null;
+ }
+
+ private boolean isCommandActivated(String name) {
+ try {
+ CommandContainer container = registry.getCommand(name, name);
+ return container.getParser().getProcessedCommand().getActivator().isActivated(null);
+ } catch (Exception e) {
+ return true; // default to activated if we can't check
+ }
+ }
+}
diff --git a/aesh/src/main/java/org/aesh/console/ReadlineConsole.java b/aesh/src/main/java/org/aesh/console/ReadlineConsole.java
index b383c4c1..be20b838 100644
--- a/aesh/src/main/java/org/aesh/console/ReadlineConsole.java
+++ b/aesh/src/main/java/org/aesh/console/ReadlineConsole.java
@@ -56,6 +56,7 @@
import org.aesh.command.invocation.CommandInvocation;
import org.aesh.command.operator.OperatorType;
import org.aesh.command.parser.CommandLineParserException;
+import org.aesh.command.registry.CommandRegistry;
import org.aesh.command.registry.CommandRegistryException;
import org.aesh.command.registry.MutableCommandRegistry;
import org.aesh.command.settings.Settings;
@@ -69,6 +70,7 @@
import org.aesh.readline.Prompt;
import org.aesh.readline.Readline;
import org.aesh.readline.ReadlineFlag;
+import org.aesh.readline.SuggestionProvider;
import org.aesh.readline.alias.AliasCompletion;
import org.aesh.readline.alias.AliasManager;
import org.aesh.readline.alias.AliasPreProcessor;
@@ -108,6 +110,7 @@ public class ReadlineConsole implements Console, Consumer {
private volatile boolean running = false;
private History history;
+ private SuggestionProvider suggestionProvider;
private ShellImpl shell;
private CommandContext commandContext;
@@ -282,6 +285,9 @@ private void init() {
}
readline = new Readline(EditModeBuilder.builder(settings.mode()).create(), history,
completionHandler);
+ if (suggestionProvider != null) {
+ readline.setSuggestionProvider(suggestionProvider);
+ }
running = true;
}
@@ -560,4 +566,31 @@ public boolean isInSubCommandMode() {
return commandContext != null && commandContext.isInSubCommandMode();
}
+ /**
+ * Sets the suggestion provider for inline ghost text suggestions.
+ *
+ * @param provider the suggestion provider
+ */
+ public void setSuggestionProvider(SuggestionProvider provider) {
+ this.suggestionProvider = provider;
+ }
+
+ /**
+ * Gets the command history.
+ *
+ * @return the history instance
+ */
+ public History getHistory() {
+ return history;
+ }
+
+ /**
+ * Gets the command registry.
+ *
+ * @return the command registry
+ */
+ public CommandRegistry> getCommandRegistry() {
+ return commandResolver.getRegistry();
+ }
+
}
diff --git a/aesh/src/test/java/org/aesh/command/completer/CommandSuggestionProviderTest.java b/aesh/src/test/java/org/aesh/command/completer/CommandSuggestionProviderTest.java
new file mode 100644
index 00000000..ee9f2c76
--- /dev/null
+++ b/aesh/src/test/java/org/aesh/command/completer/CommandSuggestionProviderTest.java
@@ -0,0 +1,229 @@
+package org.aesh.command.completer;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+import org.aesh.command.Command;
+import org.aesh.command.CommandDefinition;
+import org.aesh.command.CommandException;
+import org.aesh.command.CommandResult;
+import org.aesh.command.GroupCommandDefinition;
+import org.aesh.command.activator.CommandActivator;
+import org.aesh.command.activator.OptionActivator;
+import org.aesh.command.converter.ConverterInvocation;
+import org.aesh.command.impl.completer.CommandSuggestionProvider;
+import org.aesh.command.impl.registry.AeshCommandRegistryBuilder;
+import org.aesh.command.invocation.CommandInvocation;
+import org.aesh.command.option.Option;
+import org.aesh.command.registry.CommandRegistry;
+import org.aesh.command.registry.CommandRegistryException;
+import org.aesh.command.settings.Settings;
+import org.aesh.command.settings.SettingsBuilder;
+import org.aesh.command.validator.ValidatorInvocation;
+import org.aesh.console.ReadlineConsole;
+import org.aesh.readline.CompositeSuggestionProvider;
+import org.aesh.readline.Prompt;
+import org.aesh.readline.SuggestionProvider;
+import org.aesh.tty.TestConnection;
+import org.junit.Test;
+
+public class CommandSuggestionProviderTest {
+
+ @Test
+ public void testCommandNameSuggestion() throws CommandRegistryException {
+ CommandRegistry registry = AeshCommandRegistryBuilder. builder()
+ .command(CacheCommand.class)
+ .command(ConnectCommand.class)
+ .create();
+
+ CommandSuggestionProvider provider = new CommandSuggestionProvider<>(registry);
+
+ // "ca" should suggest "che" (from "cache")
+ assertEquals("che", provider.suggest("ca"));
+
+ // "con" should suggest "nect" (from "connect")
+ assertEquals("nect", provider.suggest("con"));
+
+ // "c" is ambiguous (cache and connect) -> null
+ assertNull(provider.suggest("c"));
+
+ // Full command name -> null
+ assertNull(provider.suggest("cache"));
+
+ // Empty -> null
+ assertNull(provider.suggest(""));
+ assertNull(provider.suggest(null));
+ }
+
+ @Test
+ public void testSubcommandSuggestion() throws CommandRegistryException {
+ CommandRegistry registry = AeshCommandRegistryBuilder. builder()
+ .command(GitCommand.class)
+ .create();
+
+ CommandSuggestionProvider provider = new CommandSuggestionProvider<>(registry);
+
+ // "git co" should suggest "mmit" (from "commit")
+ assertEquals("mmit", provider.suggest("git co"));
+
+ // "git r" should suggest "ebase" (from "rebase", only one match)
+ assertEquals("ebase", provider.suggest("git r"));
+ }
+
+ @Test
+ public void testOptionSuggestion() throws CommandRegistryException {
+ CommandRegistry registry = AeshCommandRegistryBuilder. builder()
+ .command(ConnectCommand.class)
+ .create();
+
+ CommandSuggestionProvider provider = new CommandSuggestionProvider<>(registry);
+
+ // "connect --ho" should suggest "st=" (from "--host")
+ assertEquals("st=", provider.suggest("connect --ho"));
+
+ // "connect --p" is ambiguous (port and password) -> null
+ assertNull(provider.suggest("connect --p"));
+ }
+
+ @Test
+ public void testGroupCommandOptionSuggestion() throws CommandRegistryException {
+ CommandRegistry registry = AeshCommandRegistryBuilder. builder()
+ .command(GitCommand.class)
+ .create();
+
+ CommandSuggestionProvider provider = new CommandSuggestionProvider<>(registry);
+
+ // "git commit --al" should suggest "l" (from "--all", boolean so no =)
+ assertEquals("l", provider.suggest("git commit --al"));
+ }
+
+ @Test
+ public void testCompositeSuggestionProvider() throws CommandRegistryException {
+ CommandRegistry registry = AeshCommandRegistryBuilder. builder()
+ .command(CacheCommand.class)
+ .create();
+
+ // History provider that returns null for everything
+ SuggestionProvider historyProvider = buffer -> null;
+ CommandSuggestionProvider commandProvider = new CommandSuggestionProvider<>(registry);
+
+ CompositeSuggestionProvider composite = new CompositeSuggestionProvider(historyProvider, commandProvider);
+
+ // With no history match, falls through to command provider
+ assertEquals("che", composite.suggest("ca"));
+
+ // History provider that returns a match
+ SuggestionProvider historyWithMatch = buffer -> {
+ if (buffer.equals("ca"))
+ return "che --name=test";
+ return null;
+ };
+
+ CompositeSuggestionProvider compositeWithHistory = new CompositeSuggestionProvider(historyWithMatch, commandProvider);
+
+ // History match takes priority
+ assertEquals("che --name=test", compositeWithHistory.suggest("ca"));
+ }
+
+ @Test
+ public void testIntegrationWithConsole() throws Exception {
+ TestConnection connection = new TestConnection(false);
+
+ CommandRegistry registry = AeshCommandRegistryBuilder.builder()
+ .command(CacheCommand.class)
+ .command(ConnectCommand.class)
+ .create();
+
+ Settings settings = SettingsBuilder
+ .builder()
+ .logging(true)
+ .enableAlias(false)
+ .connection(connection)
+ .commandRegistry(registry)
+ .build();
+
+ ReadlineConsole console = new ReadlineConsole(settings);
+ console.setPrompt(new Prompt(""));
+
+ // Set up command suggestion provider using the registry from console
+ console.setSuggestionProvider(new CommandSuggestionProvider<>(console.getCommandRegistry()));
+
+ console.start();
+
+ connection.clearOutputBuffer();
+ connection.read("ca");
+
+ // Ghost text is rendered with ANSI DIM codes; in non-stripped mode we should see "che" in the output
+ Thread.sleep(50);
+ String output = connection.getOutputBuffer();
+ // The output should contain both the typed text and the ghost suggestion
+ // "ca" is typed, then "che" is shown as ghost text (with ANSI codes around it)
+ assert output.contains("ca") : "Output should contain typed text 'ca', got: " + output;
+ assert output.contains("che") : "Output should contain ghost suggestion 'che', got: " + output;
+
+ console.stop();
+ }
+
+ // --- Test command classes ---
+
+ @CommandDefinition(name = "cache", description = "Cache operations")
+ public static class CacheCommand implements Command {
+ @Option
+ private String name;
+
+ @Override
+ public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException {
+ return CommandResult.SUCCESS;
+ }
+ }
+
+ @CommandDefinition(name = "connect", description = "Connect to a server")
+ public static class ConnectCommand implements Command {
+ @Option
+ private String host;
+
+ @Option
+ private int port;
+
+ @Option
+ private String password;
+
+ @Override
+ public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException {
+ return CommandResult.SUCCESS;
+ }
+ }
+
+ @GroupCommandDefinition(name = "git", description = "Git operations", groupCommands = { GitCommit.class, GitRebase.class })
+ public static class GitCommand implements Command {
+ @Override
+ public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException {
+ return CommandResult.SUCCESS;
+ }
+ }
+
+ @CommandDefinition(name = "commit", description = "Commit changes")
+ public static class GitCommit implements Command {
+ @Option(hasValue = false)
+ private boolean all;
+
+ @Option
+ private String message;
+
+ @Override
+ public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException {
+ return CommandResult.SUCCESS;
+ }
+ }
+
+ @CommandDefinition(name = "rebase", description = "Rebase branch")
+ public static class GitRebase implements Command {
+ @Option(hasValue = false)
+ private boolean force;
+
+ @Override
+ public CommandResult execute(CommandInvocation invocation) throws CommandException, InterruptedException {
+ return CommandResult.SUCCESS;
+ }
+ }
+}
diff --git a/pom.xml b/pom.xml
index 21287a8b..969d2551 100644
--- a/pom.xml
+++ b/pom.xml
@@ -53,7 +53,7 @@
1.8
1.8
- 3.1
+ 3.2
0.1.0
UTF-8