From 617258e26a7f92ced2371ac3adcb5bdef8b1effa Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Sat, 7 Nov 2026 11:20:14 +0100 Subject: [PATCH 1/3] feat: make help width configurable instead of hardcoded 80 Help text was always wrapped to the hardcoded HELP_WIDTH (80), regardless of terminal size or user preference. Add resolveHelpWidth(): an explicit override wins, in order the `aesh.help.width` system property then the `COLUMNS` environment variable (as many CLIs honour), otherwise the 80 default. A resolved value is clamped to a 40-column minimum so a tiny or bogus override cannot garble wrapping, and non-numeric values are ignored. printHelp() and the command-list description wrap now use the resolved width. HELP_WIDTH stays as the documented default. --- .../impl/internal/ProcessedCommand.java | 37 +++++++- .../impl/parser/AeshCommandLineParser.java | 2 +- .../org/aesh/command/help/HelpWidthTest.java | 91 +++++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java index a3238a4c..e6f8360a 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java @@ -63,10 +63,43 @@ public class ProcessedCommand, CI extends CommandInvocation> { /** - * The width the help is written for: longer descriptions are wrapped. + * The default width the help is written for when no override is set: + * longer descriptions are wrapped. See {@link #resolveHelpWidth()}. */ public static final int HELP_WIDTH = 80; + /** Lower bound for a resolved help width, so a tiny override never garbles wrapping. */ + private static final int MIN_HELP_WIDTH = 40; + + /** + * The width help is wrapped to. An explicit override wins, in order: the + * {@code aesh.help.width} system property, then the {@code COLUMNS} + * environment variable (as many CLIs honour); otherwise the default + * {@link #HELP_WIDTH}. A resolved value is clamped to at least + * {@link #MIN_HELP_WIDTH} so a bogus or tiny override cannot garble the + * output. Non-numeric values are ignored. + * + * @return the help width in columns + */ + public static int resolveHelpWidth() { + int width = parseWidth(System.getProperty("aesh.help.width")); + if (width <= 0) + width = parseWidth(System.getenv("COLUMNS")); + if (width <= 0) + return HELP_WIDTH; + return Math.max(width, MIN_HELP_WIDTH); + } + + private static int parseWidth(String value) { + if (value == null) + return -1; + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + private static final Pattern DESCRIPTION_VARIABLE_PATTERN = Pattern.compile("\\$\\{([^}]+)}"); private static final char DASH = '-'; private static final char EQUALS = '='; @@ -1380,7 +1413,7 @@ public String printHelp(String commandName, boolean supportsHyperlinks) { public String printHelp(String commandName, boolean supportsHyperlinks, boolean showAll) { int maxLength = 0; int maxOptionColumnWidth = 24; - int width = HELP_WIDTH; + int width = resolveHelpWidth(); DescriptionResolver descriptionResolver = new DescriptionResolver(name, commandName, null, null, null); List opts = getDisplayOptions(); List visibleOpts = new ArrayList<>(opts.size()); diff --git a/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java b/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java index 6dc09fb5..b9d38fa7 100644 --- a/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java +++ b/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java @@ -1228,7 +1228,7 @@ public String getFormattedCommand(int offset, int descriptionStart) { ""); List lines = ProcessedOption.wrapText( resolveDescriptionVariables(getProcessedCommand().description(), null), - ProcessedCommand.HELP_WIDTH - pad.length()); + ProcessedCommand.resolveHelpWidth() - pad.length()); for (int i = 0; i < lines.size(); i++) { if (i > 0) ansiBuilder.append(Config.getLineSeparator()).append(pad); diff --git a/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java b/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java new file mode 100644 index 00000000..3883d23e --- /dev/null +++ b/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java @@ -0,0 +1,91 @@ +/* + * Licensed 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 + */ +package org.aesh.command.help; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.aesh.command.Command; +import org.aesh.command.CommandDefinition; +import org.aesh.command.CommandResult; +import org.aesh.command.impl.container.AeshCommandContainerBuilder; +import org.aesh.command.impl.internal.ProcessedCommand; +import org.aesh.command.impl.parser.CommandLineParser; +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.option.Option; +import org.junit.After; +import org.junit.Test; + +/** + * The help width defaults to {@link ProcessedCommand#HELP_WIDTH} but can be + * overridden with the {@code aesh.help.width} system property (or the + * {@code COLUMNS} environment variable), clamped to a sane minimum. + */ +public class HelpWidthTest { + + @After + public void clearProperty() { + System.clearProperty("aesh.help.width"); + } + + @Test + public void testDefaultWidthWhenNoOverride() { + System.clearProperty("aesh.help.width"); + assertEquals(ProcessedCommand.HELP_WIDTH, ProcessedCommand.resolveHelpWidth()); + } + + @Test + public void testSystemPropertyOverride() { + System.setProperty("aesh.help.width", "120"); + assertEquals(120, ProcessedCommand.resolveHelpWidth()); + } + + @Test + public void testTinyOverrideIsClamped() { + System.setProperty("aesh.help.width", "5"); + assertTrue("a tiny width is clamped up, got: " + ProcessedCommand.resolveHelpWidth(), + ProcessedCommand.resolveHelpWidth() >= 40); + } + + @Test + public void testNonNumericOverrideIgnored() { + System.setProperty("aesh.help.width", "wide"); + assertEquals(ProcessedCommand.HELP_WIDTH, ProcessedCommand.resolveHelpWidth()); + } + + @Test + public void testDescriptionWrapsAtConfiguredWidth() throws Exception { + System.setProperty("aesh.help.width", "120"); + String wide = help(new WideCommand()); + System.setProperty("aesh.help.width", "40"); + String narrow = help(new WideCommand()); + + // The same help wraps onto more lines when the width is smaller. + assertTrue("narrow help should wrap onto more lines than wide help\nwide:\n" + wide + + "\nnarrow:\n" + narrow, + narrow.split("\\R").length > wide.split("\\R").length); + } + + private static String help(Command command) throws Exception { + CommandLineParser parser = new AeshCommandContainerBuilder() + .create(command).getParser(); + parser.updateAnsiMode(false); + return parser.printHelp(); + } + + @CommandDefinition(name = "wide", description = "This command has a deliberately long description so that it wraps differently depending on the configured help width used by the renderer.", generateHelp = true) + public static class WideCommand implements Command { + @Option(description = "A fairly long option description that also wraps when the help width is made narrow enough to force it") + String value; + + @Override + public CommandResult execute(CommandInvocation invocation) { + return CommandResult.SUCCESS; + } + } +} From 6bde9aca5e4541d5fefcd8e9cfe4d16c0f804930 Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Sat, 7 Nov 2026 11:33:20 +0100 Subject: [PATCH 2/3] feat: resolve help width from the live terminal, resiliently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build on the configurable help width: besides the aesh.help.width system property and COLUMNS, resolve the live terminal width when help is actually printed interactively. Why a lazily-queried hook rather than reading the terminal eagerly: printHelp() also runs where there is no terminal — documentation generation and programmatic/LazyHelp rendering — so querying a terminal unconditionally would either fail or force a terminal connection that the caller never wanted. The width is therefore resolved through an optional IntSupplier installed only by a caller that owns a terminal (AeshCommandInvocation.getHelpInfo), consulted here and only here (when usage prints), and fully guarded: a missing or misbehaving terminal (or a non-positive size) falls back to the override, then to the 80 default. Any resolved value is clamped to a 40-column minimum. Resolution order in ProcessedCommand.helpWidth(): aesh.help.width -> COLUMNS -> live terminal (if a supplier is set) -> HELP_WIDTH. The static resolveHelpWidth() keeps the terminal-free override-or-default behaviour for callers with no render context. The supplier is propagated to child commands so subcommand list entries wrap to the same width. --- .../impl/internal/ProcessedCommand.java | 83 ++++++++++++++++--- .../invocation/AeshCommandInvocation.java | 16 +++- .../impl/parser/AeshCommandLineParser.java | 6 +- .../org/aesh/command/help/HelpWidthTest.java | 42 ++++++++++ 4 files changed, 132 insertions(+), 15 deletions(-) diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java index e6f8360a..2e236dcf 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java @@ -63,31 +63,88 @@ public class ProcessedCommand, CI extends CommandInvocation> { /** - * The default width the help is written for when no override is set: - * longer descriptions are wrapped. See {@link #resolveHelpWidth()}. + * The default width the help is written for when nothing else resolves it: + * longer descriptions are wrapped. See {@link #helpWidth()}. */ public static final int HELP_WIDTH = 80; - /** Lower bound for a resolved help width, so a tiny override never garbles wrapping. */ + /** Lower bound for a resolved help width, so a tiny value never garbles wrapping. */ private static final int MIN_HELP_WIDTH = 40; /** - * The width help is wrapped to. An explicit override wins, in order: the - * {@code aesh.help.width} system property, then the {@code COLUMNS} - * environment variable (as many CLIs honour); otherwise the default - * {@link #HELP_WIDTH}. A resolved value is clamped to at least - * {@link #MIN_HELP_WIDTH} so a bogus or tiny override cannot garble the - * output. Non-numeric values are ignored. + * Optional, lazily-consulted source of the live terminal width, queried + * only while help is being rendered (see {@link #helpWidth()}). It is set + * by whoever prints usage and has a real terminal, and is {@code transient} + * on purpose: it is never part of the command definition, only a per-render + * hint. Callers with no terminal (documentation generation, programmatic + * {@code printHelp}) leave it null and so never touch a terminal. + */ + private transient java.util.function.IntSupplier terminalWidthSupplier; + + /** + * Install a terminal-width source, consulted lazily by {@link #helpWidth()} + * the next time help is rendered. Pass {@code null} to clear it. + */ + public void setTerminalWidthSupplier(java.util.function.IntSupplier terminalWidthSupplier) { + this.terminalWidthSupplier = terminalWidthSupplier; + } + + /** The terminal-width source, or null; used to propagate it to child commands. */ + public java.util.function.IntSupplier terminalWidthSupplier() { + return terminalWidthSupplier; + } + + /** + * The width this command's help should wrap to, resolved at render time. + * Resolution order: + *
    + *
  1. an explicit override: the {@code aesh.help.width} system property, + * then the {@code COLUMNS} environment variable (as many CLIs honour);
  2. + *
  3. the live terminal width, but only if a supplier was installed via + * {@link #setTerminalWidthSupplier}, and it is queried here and only here + * (when usage actually prints) and guarded so a missing or misbehaving + * terminal falls through instead of breaking help;
  4. + *
  5. the default {@link #HELP_WIDTH}.
  6. + *
+ * Any resolved value is clamped to at least {@link #MIN_HELP_WIDTH}. + * + * @return the help width in columns + */ + public int helpWidth() { + int override = overrideWidth(); + if (override > 0) + return override; + java.util.function.IntSupplier supplier = terminalWidthSupplier; + if (supplier != null) { + try { + int width = supplier.getAsInt(); + if (width > 0) + return Math.max(width, MIN_HELP_WIDTH); + } catch (Throwable ignored) { + // No terminal, or it misbehaved: fall through to the default. + } + } + return HELP_WIDTH; + } + + /** + * The help width from an explicit override only ({@code aesh.help.width} + * system property, then {@code COLUMNS}), or {@link #HELP_WIDTH} when none + * is set. Does not consult any terminal, so it is safe where there is no + * render context. * * @return the help width in columns */ public static int resolveHelpWidth() { + int override = overrideWidth(); + return override > 0 ? override : HELP_WIDTH; + } + + private static int overrideWidth() { int width = parseWidth(System.getProperty("aesh.help.width")); if (width <= 0) width = parseWidth(System.getenv("COLUMNS")); - if (width <= 0) - return HELP_WIDTH; - return Math.max(width, MIN_HELP_WIDTH); + return width > 0 ? Math.max(width, MIN_HELP_WIDTH) : -1; } private static int parseWidth(String value) { @@ -1413,7 +1470,7 @@ public String printHelp(String commandName, boolean supportsHyperlinks) { public String printHelp(String commandName, boolean supportsHyperlinks, boolean showAll) { int maxLength = 0; int maxOptionColumnWidth = 24; - int width = resolveHelpWidth(); + int width = helpWidth(); DescriptionResolver descriptionResolver = new DescriptionResolver(name, commandName, null, null, null); List opts = getDisplayOptions(); List visibleOpts = new ArrayList<>(opts.size()); diff --git a/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java b/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java index 6b8e0716..0492ab0e 100644 --- a/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java +++ b/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java @@ -134,7 +134,21 @@ public String getHelpInfo(String commandName) { @Override public String getHelpInfo() { - return commandContainer.getParser().parsedCommand().printHelp(); + org.aesh.command.impl.parser.CommandLineParser parser = commandContainer.getParser().parsedCommand(); + // Resolve help to the live terminal width when printing interactively. + // The supplier is consulted lazily inside printHelp (only when usage + // actually prints) and is guarded, so a non-terminal shell or a size() + // failure simply falls back to the default width. + parser.getProcessedCommand().setTerminalWidthSupplier(this::terminalWidth); + return parser.printHelp(); + } + + private int terminalWidth() { + try { + return shell.size().getWidth(); + } catch (Throwable t) { + return -1; + } } @Override diff --git a/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java b/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java index b9d38fa7..466269fd 100644 --- a/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java +++ b/aesh/src/main/java/org/aesh/command/impl/parser/AeshCommandLineParser.java @@ -560,6 +560,10 @@ public String printHelp() { int maxLength = 0; if (hasChildren) { for (CommandLineParser child : parsers) { + // Children format their own one-line entry; let them resolve + // the same width as this command (e.g. the live terminal). + child.getProcessedCommand().setTerminalWidthSupplier( + processedCommand.terminalWidthSupplier()); int length = child.getProcessedCommand().name().length(); if (length > maxLength) maxLength = length; @@ -1228,7 +1232,7 @@ public String getFormattedCommand(int offset, int descriptionStart) { ""); List lines = ProcessedOption.wrapText( resolveDescriptionVariables(getProcessedCommand().description(), null), - ProcessedCommand.resolveHelpWidth() - pad.length()); + getProcessedCommand().helpWidth() - pad.length()); for (int i = 0; i < lines.size(); i++) { if (i > 0) ansiBuilder.append(Config.getLineSeparator()).append(pad); diff --git a/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java b/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java index 3883d23e..9988948a 100644 --- a/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java +++ b/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java @@ -58,6 +58,44 @@ public void testNonNumericOverrideIgnored() { assertEquals(ProcessedCommand.HELP_WIDTH, ProcessedCommand.resolveHelpWidth()); } + @Test + public void testTerminalWidthSupplierUsedWhenNoOverride() throws Exception { + ProcessedCommand pc = processedCommand(new WideCommand()); + pc.setTerminalWidthSupplier(() -> 100); + assertEquals(100, pc.helpWidth()); + } + + @Test + public void testOverrideBeatsTerminalWidthSupplier() throws Exception { + System.setProperty("aesh.help.width", "120"); + ProcessedCommand pc = processedCommand(new WideCommand()); + pc.setTerminalWidthSupplier(() -> 100); + assertEquals(120, pc.helpWidth()); + } + + @Test + public void testSupplierFailureFallsBackToDefault() throws Exception { + ProcessedCommand pc = processedCommand(new WideCommand()); + pc.setTerminalWidthSupplier(() -> { + throw new IllegalStateException("no terminal"); + }); + assertEquals(ProcessedCommand.HELP_WIDTH, pc.helpWidth()); + } + + @Test + public void testSupplierNonPositiveFallsBackToDefault() throws Exception { + ProcessedCommand pc = processedCommand(new WideCommand()); + pc.setTerminalWidthSupplier(() -> 0); + assertEquals(ProcessedCommand.HELP_WIDTH, pc.helpWidth()); + } + + @Test + public void testSupplierTinyWidthIsClamped() throws Exception { + ProcessedCommand pc = processedCommand(new WideCommand()); + pc.setTerminalWidthSupplier(() -> 5); + assertTrue("tiny terminal width clamped, got: " + pc.helpWidth(), pc.helpWidth() >= 40); + } + @Test public void testDescriptionWrapsAtConfiguredWidth() throws Exception { System.setProperty("aesh.help.width", "120"); @@ -78,6 +116,10 @@ private static String help(Command command) throws Exception return parser.printHelp(); } + private static ProcessedCommand processedCommand(Command command) throws Exception { + return new AeshCommandContainerBuilder().create(command).getParser().getProcessedCommand(); + } + @CommandDefinition(name = "wide", description = "This command has a deliberately long description so that it wraps differently depending on the configured help width used by the renderer.", generateHelp = true) public static class WideCommand implements Command { @Option(description = "A fairly long option description that also wraps when the help width is made narrow enough to force it") From 5ef5e4f91e6b02932b29827ce3f64ecfd0e4311c Mon Sep 17 00:00:00 2001 From: Max Rydahl Andersen Date: Sat, 7 Nov 2026 11:45:05 +0100 Subject: [PATCH 3/3] feat: read the controlling terminal width when printing usage The terminal-width supplier relied on the shell's own terminal, which is blind when the command's stdout is captured/redirected (e.g. a launcher that runs the app via command substitution): the shell sees a non-terminal stdout and reports the default width, even though a real terminal is right there on the controlling tty. Add ProcessedCommand.detectTerminalWidth(): on POSIX it asks the controlling terminal directly via `stty size + * This is deliberately an on-demand probe: call it only when usage is + * actually printed, never eagerly, and wire it through + * {@link #setTerminalWidthSupplier}. + * + * @return the controlling terminal width in columns, or {@code -1} + */ + public static int detectTerminalWidth() { + if (Config.isWindows()) + return -1; + Process process = null; + try { + process = new ProcessBuilder("sh", "-c", "stty size /dev/null") + .redirectErrorStream(false) + .start(); + String line; + try (java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(process.getInputStream()))) { + line = reader.readLine(); + } + if (!process.waitFor(500, java.util.concurrent.TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + return -1; + } + if (line != null) { + // stty size prints " " + String[] parts = line.trim().split("\\s+"); + if (parts.length == 2) + return parseWidth(parts[1]); + } + } catch (Throwable ignored) { + if (process != null) + process.destroyForcibly(); + } + return -1; + } + private static final Pattern DESCRIPTION_VARIABLE_PATTERN = Pattern.compile("\\$\\{([^}]+)}"); private static final char DASH = '-'; private static final char EQUALS = '='; diff --git a/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java b/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java index 0492ab0e..249ca412 100644 --- a/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java +++ b/aesh/src/main/java/org/aesh/command/impl/invocation/AeshCommandInvocation.java @@ -144,6 +144,12 @@ public String getHelpInfo() { } private int terminalWidth() { + // Ask the controlling terminal directly first: it is correct even when + // the command's stdout is captured/redirected (so the shell's own + // terminal looks absent). Fall back to the shell's reported size. + int width = org.aesh.command.impl.internal.ProcessedCommand.detectTerminalWidth(); + if (width > 0) + return width; try { return shell.size().getWidth(); } catch (Throwable t) { diff --git a/aesh/src/main/java/org/aesh/command/impl/invocation/DefaultCommandInvocation.java b/aesh/src/main/java/org/aesh/command/impl/invocation/DefaultCommandInvocation.java index 9c25b97d..6399ef74 100644 --- a/aesh/src/main/java/org/aesh/command/impl/invocation/DefaultCommandInvocation.java +++ b/aesh/src/main/java/org/aesh/command/impl/invocation/DefaultCommandInvocation.java @@ -117,7 +117,24 @@ public String getHelpInfo(String commandName) { @Override public String getHelpInfo() { - return commandContainer.getParser().parsedCommand().printHelp(); + org.aesh.command.impl.parser.CommandLineParser parser = commandContainer.getParser().parsedCommand(); + // Resolve help to the live terminal width, consulted lazily (only now, + // when usage prints) and guarded: probe the controlling terminal first + // so it is correct even when stdout is captured/redirected, then fall + // back to the shell's reported size, then the default width. + parser.getProcessedCommand().setTerminalWidthSupplier(this::terminalWidth); + return parser.printHelp(); + } + + private int terminalWidth() { + int width = org.aesh.command.impl.internal.ProcessedCommand.detectTerminalWidth(); + if (width > 0) + return width; + try { + return shell.size().getWidth(); + } catch (Throwable t) { + return -1; + } } @Override diff --git a/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java b/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java index 9988948a..516d5932 100644 --- a/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java +++ b/aesh/src/test/java/org/aesh/command/help/HelpWidthTest.java @@ -96,6 +96,14 @@ public void testSupplierTinyWidthIsClamped() throws Exception { assertTrue("tiny terminal width clamped, got: " + pc.helpWidth(), pc.helpWidth() >= 40); } + @Test + public void testDetectTerminalWidthIsSafeWithoutTerminal() { + // In a non-terminal test/CI environment this must not throw and must + // yield -1 (no tty) or a positive width; never zero or an exception. + int width = ProcessedCommand.detectTerminalWidth(); + assertTrue("detectTerminalWidth returned " + width, width == -1 || width > 0); + } + @Test public void testDescriptionWrapsAtConfiguredWidth() throws Exception { System.setProperty("aesh.help.width", "120");