This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new 09e912275e GROOVY-12003: Add /img command to groovysh for inline image 
and chart display
09e912275e is described below

commit 09e912275e770773ca0eaafbcf170176c05906e9
Author: Paul King <[email protected]>
AuthorDate: Sat May 9 21:30:18 2026 +1000

    GROOVY-12003: Add /img command to groovysh for inline image and chart 
display
---
 .../groovy/org/apache/groovy/groovysh/Main.groovy  |   2 +-
 .../groovy/groovysh/jline/GroovyCommands.groovy    | 249 +++++++++++++++++++++
 .../src/main/resources/nanorc/args.nanorc          |   2 +-
 .../src/main/resources/nanorc/groovy.nanorc        |   2 +-
 .../spec/doc/assets/img/repl_img_jfreechart.png    | Bin 0 -> 109326 bytes
 .../src/spec/doc/assets/img/repl_img_orson.png     | Bin 0 -> 191999 bytes
 .../src/spec/doc/assets/img/repl_img_smile.png     | Bin 0 -> 185630 bytes
 .../src/spec/doc/assets/img/repl_img_xchart.png    | Bin 0 -> 159142 bytes
 .../groovy-groovysh/src/spec/doc/groovysh.adoc     | 138 ++++++++++++
 .../groovy/groovysh/commands/CompletionTest.groovy |  50 +++++
 .../groovysh/commands/ConsoleCommandTest.groovy    |  49 ++++
 .../apache/groovy/groovysh/commands/DocTest.groovy |  53 +++++
 .../groovy/groovysh/commands/GrabTest.groovy       |  35 +++
 .../apache/groovy/groovysh/commands/ImgTest.groovy | 163 ++++++++++++++
 .../groovy/groovysh/commands/RoundTripTest.groovy  |  63 ++++++
 .../groovy/groovysh/jline/GroovyEngineTest.groovy  |  56 +++++
 16 files changed, 859 insertions(+), 3 deletions(-)

diff --git 
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
index ac2513bc2d..026921c06f 100644
--- 
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
+++ 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy
@@ -475,7 +475,7 @@ class Main {
                 if (!OSUtils.IS_WINDOWS) {
                     setSpecificHighlighter("/!", 
SyntaxHighlighter.build(jnanorc, "SH-REPL"))
                 }
-                addFileHighlight('/nano', '/less', '/slurp', '/load', '/save', 
*GROOVY_POSIX_CMDS, '/cd')
+                addFileHighlight('/nano', '/less', '/slurp', '/load', '/save', 
'/img', *GROOVY_POSIX_CMDS, '/cd')
                 addFileHighlight('/classloader', null, ['-a', '--add'])
                 addExternalHighlighterRefresh(printer::refresh)
                 addExternalHighlighterRefresh(scriptEngine::refresh)
diff --git 
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
index 2a9e633089..1ff0c38d55 100644
--- 
a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
+++ 
b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy
@@ -41,10 +41,25 @@ import org.jline.reader.impl.completer.AggregateCompleter
 import org.jline.reader.impl.completer.ArgumentCompleter
 import org.jline.reader.impl.completer.NullCompleter
 import org.jline.reader.impl.completer.StringsCompleter
+import org.jline.terminal.Terminal
+import org.jline.terminal.impl.TerminalGraphics
+import org.jline.terminal.impl.TerminalGraphicsManager
 import org.jline.utils.AttributedString
 
+import javax.imageio.ImageIO
+import javax.swing.ImageIcon
+import javax.swing.JComponent
+import javax.swing.JFrame
+import javax.swing.JLabel
+import javax.swing.SwingUtilities
+import javax.swing.WindowConstants
+import java.awt.Color
 import java.awt.Desktop
+import java.awt.Dimension
+import java.awt.Graphics2D
 import java.awt.event.ActionListener
+import java.awt.image.BufferedImage
+import java.awt.image.RenderedImage
 import java.lang.reflect.Method
 import java.nio.charset.Charset
 import java.nio.charset.StandardCharsets
@@ -70,6 +85,7 @@ class GroovyCommands extends JlineCommandRegistry implements 
CommandRegistry {
         '/console'     : new Tuple4<>(this::console, this::defCompleter, 
this::defCmdDesc, ['launch Groovy console']),
         '/doc'         : new Tuple4<>(this::doc, this::importsCompleter, 
this::defCmdDesc, ['display documentation']),
         '/grab'        : new Tuple4<>(this::grab, this::grabCompleter, 
this::grabCmdDesc, ['add maven repository dependencies to classpath']),
+        '/img'         : new Tuple4<>(this::img, this::imgCompleter, 
this::imgCmdDesc, ['display image inline (Sixel/Kitty/iTerm2 terminals)']),
         '/classloader' : new Tuple4<>(this::classLoader, 
this::classloaderCompleter, this::classLoaderCmdDesc, ['display/manage Groovy 
classLoader data']),
         '/imports'     : new Tuple4<>(this::importsCommand, 
this::importsCompleter, this::nameDeleteCmdDesc, ['show/delete import 
statements']),
         '/vars'        : new Tuple4<>(this::varsCommand, this::varsCompleter, 
this::nameDeleteCmdDesc, ['show/delete variable declarations']),
@@ -116,6 +132,12 @@ class GroovyCommands extends JlineCommandRegistry 
implements CommandRegistry {
             commands.remove('/grab')
         }
 
+        // /img depends on java.desktop (BufferedImage, ImageIO, Swing 
fallback).
+        // It's "static transitive" in JLine's module descriptor, so check at 
runtime.
+        if (!ClassUtils.lookFor('javax.imageio.ImageIO')) {
+            commands.remove('/img')
+        }
+
         def available = commands.collectEntries { name, tuple ->
             [name, new CommandMethods((Function)tuple.v1, tuple.v2)]
         }
@@ -215,6 +237,214 @@ class GroovyCommands extends JlineCommandRegistry 
implements CommandRegistry {
         return null
     }
 
+    /**
+     * Displays an image inline using JLine's terminal-graphics support
+     * (Sixel, Kitty, iTerm2). Falls back to a summary line when the
+     * terminal doesn't speak any supported protocol; the {@code --gui}
+     * flag opens a Swing window instead.
+     *
+     * @param input parsed command input
+     * @return always {@code null}
+     */
+    def img(CommandInput input) {
+        // No fixed arg-count cap: a fully-specified invocation
+        //   /img --width 64 --height 32 --no-preserve-aspect-ratio --gui $img
+        // is already 7 tokens. The parse loop below validates every flag and
+        // treats the lone non-option token as the positional, which is the
+        // useful constraint.
+        if (maybePrintHelp(input, '/img')) return
+        try {
+            Integer width = null
+            Integer height = null
+            boolean preserveAspect = true
+            boolean gui = false
+            Object positional = null
+            String positionalLabel = null
+            for (int i = 0; i < input.args().length; i++) {
+                String a = input.args()[i]
+                if (a == null) {
+                    // JLine puts null into args() when a $var reference 
resolves
+                    // to null — usually because the variable isn't defined 
yet.
+                    throw new IllegalArgumentException(
+                            '/img: variable reference resolved to null ' +
+                                    '(undefined or not yet assigned) — define 
it first, e.g. ' +
+                                    "'panel = 
ScatterPlot.of(...).canvas().panel()'")
+                }
+                if (a == '-w' || a == '--width') {
+                    width = Integer.parseInt(requireArgValue(input, ++i, a))
+                } else if (a.startsWith('--width=')) {
+                    width = Integer.parseInt(a.substring('--width='.length()))
+                } else if (a == '--height') {
+                    height = Integer.parseInt(requireArgValue(input, ++i, a))
+                } else if (a.startsWith('--height=')) {
+                    height = 
Integer.parseInt(a.substring('--height='.length()))
+                } else if (a == '-p' || a == '--no-preserve-aspect-ratio') {
+                    preserveAspect = false
+                } else if (a == '-g' || a == '--gui') {
+                    gui = true
+                } else if (!a.startsWith('-')) {
+                    // Use the resolved value from xargs — for "$myImage" this
+                    // is the variable's value (e.g. a BufferedImage); for a
+                    // plain string ("foo.png") it's the same string.
+                    positional = input.xargs()[i]
+                    positionalLabel = a
+                }
+            }
+            if (positional == null) {
+                throw new IllegalArgumentException('No image path, URL, or 
variable provided')
+            }
+            BufferedImage image = positional instanceof String
+                    ? loadImage((String) positional)
+                    : coerceToImage(positional, width, height)
+            if (image == null) {
+                throw new IllegalArgumentException("Not a recognised image: 
$positionalLabel")
+            }
+            // For raw-pixel inputs (file/URL/BufferedImage/RenderedImage), 
--width
+            // and --height are terminal-display dimensions (cells). For inputs
+            // that *generate* the image from those dims (createBufferedImage /
+            // toBufferedImage / JComponent paint), the values are already
+            // consumed as source pixels and must NOT also be passed to the
+            // terminal opts — otherwise e.g. "--width=600" gets reinterpreted
+            // as 600 character cells and the chart renders blank/clipped.
+            boolean dimsConsumedByGeneration = !(positional instanceof String
+                    || positional instanceof BufferedImage
+                    || positional instanceof RenderedImage)
+            Terminal terminal = input.terminal()
+            if (gui) {
+                showInSwing(image, positionalLabel)
+            } else if (TerminalGraphicsManager.isGraphicsSupported(terminal)) {
+                def opts = new 
TerminalGraphics.ImageOptions().preserveAspectRatio(preserveAspect)
+                if (!dimsConsumedByGeneration) {
+                    if (width != null) opts.width(width)
+                    if (height != null) opts.height(height)
+                }
+                TerminalGraphicsManager.displayImage(terminal, image, opts)
+                // Reset cursor to column 0 of the next line — 
Sixel/iTerm2/Kitty
+                // protocols typically leave the cursor at the right edge of
+                // the image, which would indent the next prompt.
+                terminal.writer().println()
+                terminal.writer().flush()
+            } else {
+                // Coerce to String — DefaultPrinter renders unknown Object
+                // (including GString) as a field table; we want a plain line.
+                String summary = "[image: ${image.width}x${image.height}, 
$positionalLabel] " +
+                        "(this terminal doesn't support inline images; try 
Kitty/iTerm2/WezTerm, or use --gui)"
+                printer.println(summary)
+            }
+        } catch (Exception e) {
+            saveException(e)
+        }
+        return null
+    }
+
+    private static String requireArgValue(CommandInput input, int idx, String 
flag) {
+        // Trailing-flag guard for `--width`/`--height` (and friends): without
+        // this, `/img --width $img` reads past the end of args() and surfaces
+        // an opaque ArrayIndexOutOfBoundsException via saveException.
+        if (idx >= input.args().length) {
+            throw new IllegalArgumentException("/img: missing value for $flag")
+        }
+        input.args()[idx]
+    }
+
+    private BufferedImage loadImage(String pathOrUrl) {
+        if (pathOrUrl.startsWith('http://') || 
pathOrUrl.startsWith('https://')) {
+            return ImageIO.read(URI.create(pathOrUrl).toURL())
+        }
+        Path path = workDir.get().resolve(pathOrUrl)
+        if (!Files.exists(path)) {
+            throw new IllegalArgumentException("File not found: $pathOrUrl")
+        }
+        ImageIO.read(path.toFile())
+    }
+
+    /**
+     * Converts an arbitrary value into a {@link BufferedImage} for /img.
+     * Supports:
+     * <ul>
+     *   <li>{@code BufferedImage} — used as-is</li>
+     *   <li>{@code RenderedImage} — drawn into a fresh {@code 
BufferedImage}</li>
+     *   <li>anything with {@code createBufferedImage(int, int)} (e.g.
+     *       {@code org.jfree.chart.JFreeChart}) — duck-typed so groovysh
+     *       doesn't take a hard dependency on JFreeChart</li>
+     *   <li>anything with {@code toBufferedImage(int, int)} (e.g.
+     *       {@code smile.plot.swing.Figure}) — duck-typed for Smile's
+     *       parallel naming convention</li>
+     *   <li>{@code JComponent} — laid out and painted to a {@code 
BufferedImage}</li>
+     * </ul>
+     * Other types throw {@link IllegalArgumentException} with a clear message.
+     */
+    private BufferedImage coerceToImage(Object obj, Integer width, Integer 
height) {
+        if (obj instanceof BufferedImage) {
+            return (BufferedImage) obj
+        }
+        if (obj instanceof RenderedImage) {
+            return renderedToBuffered((RenderedImage) obj)
+        }
+        // Duck-type: createBufferedImage(int, int) — JFreeChart's signature.
+        try {
+            return (BufferedImage) obj.createBufferedImage(width ?: 800, 
height ?: 600)
+        } catch (MissingMethodException ignore) {
+            // Not a JFreeChart-like — fall through.
+        }
+        // Duck-type: toBufferedImage(int, int) — Smile Figure's signature.
+        try {
+            return (BufferedImage) obj.toBufferedImage(width ?: 800, height ?: 
600)
+        } catch (MissingMethodException ignore) {
+            // Not a Smile-Figure-like — fall through.
+        }
+        if (obj instanceof JComponent) {
+            return renderJComponent((JComponent) obj, width, height)
+        }
+        throw new IllegalArgumentException(
+                "/img: don't know how to render ${obj.class.name}; supports " +
+                'BufferedImage, RenderedImage, anything with 
createBufferedImage(int,int) ' +
+                'or toBufferedImage(int,int), or JComponent')
+    }
+
+    private static BufferedImage renderedToBuffered(RenderedImage src) {
+        if (src instanceof BufferedImage) return (BufferedImage) src
+        BufferedImage out = new BufferedImage(src.width, src.height, 
BufferedImage.TYPE_INT_ARGB)
+        Graphics2D g = out.createGraphics()
+        try {
+            g.drawRenderedImage(src, new java.awt.geom.AffineTransform())
+        } finally {
+            g.dispose()
+        }
+        out
+    }
+
+    private static BufferedImage renderJComponent(JComponent comp, Integer 
width, Integer height) {
+        Dimension preferred = comp.preferredSize
+        int w = width ?: (preferred.width > 0 ? preferred.width : 800)
+        int h = height ?: (preferred.height > 0 ? preferred.height : 600)
+        comp.size = new Dimension(w, h)
+        comp.doLayout()
+        BufferedImage image = new BufferedImage(w, h, 
BufferedImage.TYPE_INT_ARGB)
+        Graphics2D g = image.createGraphics()
+        try {
+            // Most plot panels assume a light background; default JComponent
+            // paints transparent pixels which look broken when displayed.
+            g.color = Color.WHITE
+            g.fillRect(0, 0, w, h)
+            comp.paint(g)
+        } finally {
+            g.dispose()
+        }
+        image
+    }
+
+    private static void showInSwing(BufferedImage image, String title) {
+        SwingUtilities.invokeLater {
+            JFrame frame = new JFrame(title)
+            frame.defaultCloseOperation = WindowConstants.DISPOSE_ON_CLOSE
+            frame.add(new JLabel(new ImageIcon(image)))
+            frame.pack()
+            frame.locationRelativeTo = null
+            frame.visible = true
+        }
+    }
+
     /**
      * Clears the current buffer.
      *
@@ -874,6 +1104,18 @@ class GroovyCommands extends JlineCommandRegistry 
implements CommandRegistry {
         ])
     }
 
+    private CmdDesc imgCmdDesc(String name) {
+        new CmdDesc([
+            new AttributedString("$name [OPTIONS] (FILE | URL | \$VAR)"),
+        ], [], [
+            '-? --help'                       : doDescription('Displays 
command help'),
+            '-w --width=N'                    : doDescription('Width: terminal 
cells for raw images, source pixels for charts'),
+            '   --height=N'                   : doDescription('Height: 
terminal cells for raw images, source pixels for charts'),
+            '-p --no-preserve-aspect-ratio'   : doDescription("Don't preserve 
aspect ratio"),
+            '-g --gui'                        : doDescription('Open in a Swing 
window instead of inline')
+        ])
+    }
+
     private CmdDesc inspectCmdDesc(String name) {
         def optDescs = [
             '-? --help'        : doDescription('Displays command help'),
@@ -971,6 +1213,13 @@ class GroovyCommands extends JlineCommandRegistry 
implements CommandRegistry {
             new OptionCompleter([new StringsCompleter((Supplier)() -> 
engine.imports.keySet()), NullCompleter.INSTANCE], this::compileOptDescs, 1))]
     }
 
+    private List<Completer> imgCompleter(String command) {
+        // Hint common image extensions; users can still tab-complete other 
files.
+        [new ArgumentCompleter(NullCompleter.INSTANCE,
+                new OptionCompleter(new Completers.FilesCompleter(workDir),
+                        this::compileOptDescs, 1))]
+    }
+
     private List<Completer> inspectCompleter(String command) {
         [new ArgumentCompleter(NullCompleter.INSTANCE,
                 new OptionCompleter([new StringsCompleter((Supplier) 
this::variables), NullCompleter.INSTANCE],
diff --git a/subprojects/groovy-groovysh/src/main/resources/nanorc/args.nanorc 
b/subprojects/groovy-groovysh/src/main/resources/nanorc/args.nanorc
index d6267769fe..c8915ad110 100644
--- a/subprojects/groovy-groovysh/src/main/resources/nanorc/args.nanorc
+++ b/subprojects/groovy-groovysh/src/main/resources/nanorc/args.nanorc
@@ -17,7 +17,7 @@ syntax "ARGS"
 
 NUMBER:         "\<[-]?[0-9]*([Ee][+-]?[0-9]+)?\>"  "\<[-]?[0](\.[0-9]+)?\>"
 STRING:         "[a-zA-Z]+[a-zA-Z0-9]*"
-COMMAND:        
"\</?(alias|classloader|clear|colors|console|del|doc|echo|exit|grab|help|highlighter|history|imports|inspect|keymap|less|load|methods|nano|pipe|prnt|reset|save|setopt|show|slurp|ttop|types|unalias|unsetopt|vars|widget)\>"
+COMMAND:        
"\</?(alias|classloader|clear|colors|console|del|doc|echo|exit|grab|help|highlighter|history|imports|img|inspect|keymap|less|load|methods|nano|pipe|prnt|reset|save|setopt|show|slurp|ttop|types|unalias|unsetopt|vars|widget)\>"
 NULL:           "\<null\>"
 BOOLEAN:        "\<(true|false)\>"
 VARIABLE:       "(\[|,)\s*[a-zA-Z0-9]*\s*:"
diff --git 
a/subprojects/groovy-groovysh/src/main/resources/nanorc/groovy.nanorc 
b/subprojects/groovy-groovysh/src/main/resources/nanorc/groovy.nanorc
index 557f54cb3f..a13f280867 100644
--- a/subprojects/groovy-groovysh/src/main/resources/nanorc/groovy.nanorc
+++ b/subprojects/groovy-groovysh/src/main/resources/nanorc/groovy.nanorc
@@ -15,7 +15,7 @@
 
 syntax "Groovy" "\.(groovy|gradle)$"
 
-TYPE:        
"\<(boolean|byte|char|def|double|float|int|it|long|new|short|this|transient|var|void)\>"
+TYPE:        
"\<(boolean|byte|char|def|double|float|int|it|long|new|short|this|transient|val|var|void)\>"
 KEYWORD:     
"\<(case|catch|default|do|else|finally|for|if|return|switch|throw|try|while)\>"
 KEYWORD:     
"\<(abstract|class|extends|final|implements|import|instanceof|interface|native|non-sealed|package)\>"
 KEYWORD:     
"\<(permits|private|protected|public|record|sealed|static|strictfp|super|synchronized|throws|trait|volatile)\>"
diff --git 
a/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_jfreechart.png 
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_jfreechart.png
new file mode 100644
index 0000000000..0d958ec908
Binary files /dev/null and 
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_jfreechart.png 
differ
diff --git 
a/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_orson.png 
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_orson.png
new file mode 100644
index 0000000000..8ab332d333
Binary files /dev/null and 
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_orson.png differ
diff --git 
a/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_smile.png 
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_smile.png
new file mode 100644
index 0000000000..83fdb63737
Binary files /dev/null and 
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_smile.png differ
diff --git 
a/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_xchart.png 
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_xchart.png
new file mode 100644
index 0000000000..18829168a0
Binary files /dev/null and 
b/subprojects/groovy-groovysh/src/spec/doc/assets/img/repl_img_xchart.png differ
diff --git a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc 
b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
index 6b0bb47621..cf311f46c7 100644
--- a/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
+++ b/subprojects/groovy-groovysh/src/spec/doc/groovysh.adoc
@@ -720,6 +720,144 @@ groovy> /imports
 import java.util.concurrent.BlockingQueue
 --------------
 
+[[GroovyShell-img]]
+==== `/img`
+
+The `/img` command displays an image inline using JLine's terminal-graphics
+support (Sixel, Kitty, or iTerm2 protocols, auto-detected). The argument is
+either a local file path, an `http(s)://` URL, or a Groovy variable
+reference using the standard `$` syntax.
+
+[source,jshell]
+--------------
+groovy> /img chart.png
+groovy> /img --width=80 https://example.com/diagram.png
+groovy> img = new java.awt.image.BufferedImage(200, 100, 1)  // some 
BufferedImage
+groovy> /img $img
+--------------
+
+When the argument resolves to a Groovy value (rather than a path), `/img`
+will accept any of:
+
+* a `java.awt.image.BufferedImage` — used as-is
+* a `java.awt.image.RenderedImage` — drawn into a `BufferedImage`
+* anything with a `createBufferedImage(int, int)` method (e.g.
+  `org.jfree.chart.JFreeChart`) — duck-typed, no compile-time dependency
+* anything with a `toBufferedImage(int, int)` method (e.g. Smile's
+  `smile.plot.swing.Figure`) — duck-typed sibling for libraries that
+  follow the `to…` rather than `create…` naming convention
+* a `javax.swing.JComponent` (e.g. Smile's `Canvas` and `MultiFigurePane`) —
+  laid out and painted to a `BufferedImage` at the requested size
+
+So once you've grabbed JFreeChart you can render charts in the REPL
+without saving to a file:
+
+[source,jshell]
+--------------
+groovy> /grab org.jfree:jfreechart:1.5.6
+groovy> import org.jfree.data.category.DefaultCategoryDataset
+groovy> ds = new DefaultCategoryDataset()
+groovy> ds.addValue(3d, 'Series', 'Mon'); ds.addValue(5d, 'Series', 'Tue')
+groovy> ds.addValue(7d, 'Series', 'Wed'); ds.addValue(4d, 'Series', 'Thu')
+groovy> import org.jfree.chart.ChartFactory
+groovy> chart = ChartFactory.createBarChart('demo', 'day', 'count', ds)
+groovy> /img $chart --width=600 --height=400
+--------------
+
+image:{reldir_groovysh}/assets/img/repl_img_jfreechart.png[Using /img with 
JFreeChart]
+
+Smile is similar — `Figure.toBufferedImage(int, int)` matches the second
+duck-type, so a fresh `Figure` can be handed straight to `/img`. The
+following draws the classic Iris scatter plot:
+
+[source,jshell]
+--------------
+groovy> /grab com.github.haifengl:smile-io:3.1.0
+groovy> import smile.io.Read
+groovy> iris = Read.arff('iris.arff')
+groovy> import smile.plot.swing.ScatterPlot
+groovy> figure = ScatterPlot.of(iris, 'sepallength', 'sepalwidth', 'class', 
'*' as char).figure()
+groovy> figure.setAxisLabels('sepallength', 'sepalwidth')
+groovy> /img $figure --width=600 --height=400
+--------------
+
+image:{reldir_groovysh}/assets/img/repl_img_smile.png[Using /img with Smile]
+
+Smile's `Canvas` and `MultiFigurePane` are themselves `JComponent` subclasses,
+so the SPLOM (scatter-plot matrix) goes through the `JComponent` path with no
+extra wrapping:
+
+[source,jshell]
+--------------
+groovy> import smile.plot.swing.MultiFigurePane
+groovy> splom = MultiFigurePane.splom(iris, '*' as char, 'class')
+groovy> /img $splom --width=900 --height=900
+--------------
+
+Orson Charts gives 3D chart shapes (pie, bar, scatter, surface, …).
+`Chart3D` doesn't expose `createBufferedImage` directly, but pairs with
+`Chart3DPanel` (a `JComponent`) which reaches `/img` through the same
+JComponent path as Smile's SPLOM. Pairing that with Apache Commons CSV
+— handed a `population.csv` whose columns are `country` and `millions`
+— gives a 3D pie of the world's ten most populous countries:
+
+[source,jshell]
+--------------
+groovy> /grab org.apache.commons:commons-csv:1.14.1
+groovy> import static org.apache.commons.csv.CSVFormat.RFC4180 as CSV
+groovy> parser = CSV.builder().setHeader().setSkipHeaderRecord(true).build()
+groovy> records = parser.parse(new FileReader('population.csv'))
+groovy> /grab org.jfree:org.jfree.chart3d:2.1.1
+groovy> import module org.jfree.chart3d
+groovy> data = new StandardPieDataset3D()
+groovy> records.each { data.add(it.country, it.millions as double) }
+groovy> chart = Chart3DFactory.createPieChart('Top 10 by population 
(millions)', null, data)
+groovy> panel = new Chart3DPanel(chart)
+groovy> /img $panel --width=700 --height=420
+--------------
+
+image:{reldir_groovysh}/assets/img/repl_img_orson.png[Using /img with Orson 
Charts]
+
+XChart's `BitmapEncoder.getBufferedImage(chart)` yields a plain
+`BufferedImage`, which is the simplest path of all — assign it to a
+variable and hand it straight to `/img`. Combining that with the
+`/slurp` command (which prefers `groovy-csv`'s `CsvSlurper` and falls
+back to Apache Commons CSV) gives a declarative CSV-to-chart pipeline.
+Reading a `co2-mlo.csv` whose columns are `Year` and `Mean` (annual mean
+Mauna Loa CO2 in ppm) draws the iconic Keeling-curve line:
+
+[source,jshell]
+--------------
+groovy> /grab org.knowm.xchart:xchart:3.8.8
+groovy> rows = /slurp co2-mlo.csv
+groovy> import org.knowm.xchart.XYChartBuilder
+groovy> chart = new XYChartBuilder().title('Mauna Loa annual mean 
CO2').xAxisTitle('Year').yAxisTitle('CO2 (ppm)').width(600).height(400).build()
+groovy> chart.addSeries('CO2', rows.collect { it.Year as int }, rows.collect { 
it.Mean as double })
+groovy> import org.knowm.xchart.BitmapEncoder
+groovy> img = BitmapEncoder.getBufferedImage(chart)
+groovy> /img $img
+--------------
+
+image:{reldir_groovysh}/assets/img/repl_img_xchart.png[Using /img with XChart]
+
+The `--width` and `--height` options have two distinct meanings depending
+on the input. For raw-pixel inputs (file path, `http(s)://` URL,
+`BufferedImage`, `RenderedImage`), they control the rendered size in
+*terminal character cells*. For inputs that generate the image at the
+requested size (`createBufferedImage(int,int)`, `toBufferedImage(int,int)`,
+`JComponent`), they are *source-image pixels* — the terminal then renders
+the image at its natural fit. Aspect ratio is preserved by default
+(override with `--no-preserve-aspect-ratio`).
+
+If the active terminal doesn't speak any of the supported graphics protocols
+(common cases: macOS Terminal.app, VS Code's built-in terminal, JetBrains
+terminals, plain Windows console), `/img` prints a `[image: WxH, label]`
+summary line instead. Pass `--gui` to open a Swing window with the image
+regardless of terminal capability.
+
+The command requires the `java.desktop` module at runtime; on a JVM where
+that module is unavailable, `/img` is not registered.
+
 [[GroovyShell-inspect]]
 ==== `/inspect`
 
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/CompletionTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/CompletionTest.groovy
new file mode 100644
index 0000000000..1ac678735b
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/CompletionTest.groovy
@@ -0,0 +1,50 @@
+/*
+ *  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.groovy.groovysh.commands
+
+import org.junit.jupiter.api.Test
+
+/**
+ * Smoke test for tab completion. Each registered command in
+ * {@link org.apache.groovy.groovysh.jline.GroovyCommands} wires up a
+ * completer factory; previously, none of those factories were exercised
+ * by any test.
+ *
+ * <p>The smoke we test here is "every factory compiles without throwing".
+ * Driving end-to-end completion (typing a partial line, asserting
+ * candidates) needs a real LineReader pumping the parser — this unit
+ * harness can't fake that reliably. Even so, this catches the most
+ * common breakage: a typo or missing import in a completer factory
+ * function that would NPE the first time a user hits TAB.
+ */
+class CompletionTest extends SystemTestSupport {
+
+    @Test
+    void everyCommandsCompleterFactoryCompilesWithoutThrowing() {
+        // CommandRegistry.compileCompleters() invokes every per-command
+        // completer factory function. If any of them throws (typo in a
+        // class reference, missing JLine import, broken constructor
+        // chain), this test surfaces it.
+        def systemCompleter = groovy.compileCompleters()
+        assert systemCompleter != null
+        assert !systemCompleter.compiled
+        systemCompleter.compile()
+        assert systemCompleter.compiled
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy
new file mode 100644
index 0000000000..950834e402
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy
@@ -0,0 +1,49 @@
+/*
+ *  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.groovy.groovysh.commands
+
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests for the {@code /console} command. The command launches a Swing
+ * Groovy console window — we can't (and shouldn't) drive that in CI, but
+ * the registration and help paths are deterministic.
+ */
+class ConsoleCommandTest extends SystemTestSupport {
+
+    @Test
+    void consoleIsRegisteredWhenObjectBrowserOnClasspath() {
+        // /console is conditionally registered: present iff
+        // groovy.console.ui.ObjectBrowser is on the classpath. The test
+        // module pulls groovy-console in transitively, so it should be
+        // there. If anyone removes that dep without intent, this fails.
+        assert '/console' in groovy.commandNames()
+    }
+
+    @Test
+    void consoleHelpFlagDoesNotLaunchTheConsole() {
+        // maybePrintHelp short-circuits before `new Console(...)`, so
+        // `/console --help` is the only way to exercise the command in a
+        // headless test without opening a frame. Asserts on output growth
+        // — the help machinery captures via the printer.
+        int before = printer.output.size()
+        system.execute('/console --help')
+        assert printer.output.size() > before
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DocTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DocTest.groovy
new file mode 100644
index 0000000000..142cdd8473
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DocTest.groovy
@@ -0,0 +1,53 @@
+/*
+ *  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.groovy.groovysh.commands
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Tests for the {@code /doc} command. The command opens documentation in
+ * a browser via {@link java.awt.Desktop} when configured, but the early
+ * exit and error paths (no args, missing config, headless JVM) are
+ * deterministic and cheap to cover.
+ */
+class DocTest extends SystemTestSupport {
+
+    @Test
+    void docWithNoArgsIsNoOp() {
+        // doc() returns early when xargs is empty; no exception, no output.
+        system.execute('/doc')
+    }
+
+    @Test
+    void docForUnknownTargetSurfacesAClearError() {
+        // Without a CONSOLE_OPTIONS map, /doc throws IllegalStateException.
+        // The exact message varies by environment:
+        //   - headless JVM: "Desktop is not supported!"
+        //   - desktop dev box: "No documents configuration!"
+        // Don't pin to either; just lock in that the failure is targeted
+        // and not, e.g., an NPE walking through xargs.
+        def thrown = shouldFail(IllegalStateException) {
+            system.execute('/doc List')
+        }
+        assert thrown.message != null
+        assert !thrown.message.empty
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy
index 662b75a890..49f62ee7a8 100644
--- 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy
@@ -22,6 +22,8 @@ import groovy.junit6.plugin.ForkedJvm
 import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.condition.EnabledIfSystemProperty
 
+import static groovy.test.GroovyAssert.shouldFail
+
 /**
  * Tests for the {@code /grab} command — Maven-coordinate dependency
  * resolution via Grape. The actual artifact-fetching test is forked and
@@ -50,4 +52,37 @@ class GrabTest extends SystemTestSupport {
         assert cls != null
         assert cls.name == 'org.apache.commons.lang3.StringUtils'
     }
+
+    @Test
+    void grabWithMalformedCoordsRaisesAClearError() {
+        // Coords must be group:module:version (3 colon-separated parts).
+        // An incomplete spec should fail fast with a targeted message
+        // rather than reaching the network and timing out.
+        def thrown = shouldFail(IllegalArgumentException) {
+            system.execute('/grab org.apache.commons:commons-lang3')
+        }
+        assert thrown.message.contains('Invalid command parameter')
+        assert thrown.message.contains('commons-lang3')
+    }
+
+    @Test
+    void grabWithUnknownTwoArgFlagRaisesAClearError() {
+        // Two-arg form only accepts -v/--verbose. Anything else (here a
+        // bogus -x) is rejected before any network attempt.
+        def thrown = shouldFail(IllegalArgumentException) {
+            system.execute('/grab -x foo:bar:1.0')
+        }
+        assert thrown.message.contains('Unknown command parameters')
+    }
+
+    @Test
+    void grabListEnumeratesCachedGrapes() {
+        // /grab --list calls Grape.instance.enumerateGrapes() which is
+        // local-only (no network); even with an empty cache it returns
+        // an empty map without throwing. Verifies the --list branch is
+        // reachable and produces output via the printer.
+        int before = printer.output.size()
+        system.execute('/grab --list')
+        assert printer.output.size() > before
+    }
 }
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ImgTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ImgTest.groovy
new file mode 100644
index 0000000000..31bb6067ef
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ImgTest.groovy
@@ -0,0 +1,163 @@
+/*
+ *  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.groovy.groovysh.commands
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import javax.imageio.ImageIO
+import javax.swing.JLabel
+import java.awt.image.BufferedImage
+import java.nio.file.Path
+
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Smoke tests for the {@code /img} command. Asserts on the fallback path
+ * (dumb terminal doesn't speak Sixel/Kitty/iTerm2) — the actual ANSI-image
+ * emission isn't testable through a captured byte stream without a real
+ * graphics-protocol terminal.
+ */
+class ImgTest extends SystemTestSupport {
+
+    @TempDir
+    Path tmp
+
+    private Path writePng(String name, int w, int h) {
+        Path file = tmp.resolve(name)
+        BufferedImage image = new BufferedImage(w, h, 
BufferedImage.TYPE_INT_RGB)
+        ImageIO.write(image, 'png', file.toFile())
+        file
+    }
+
+    @Test
+    void unsupportedTerminalProducesSummaryLine() {
+        Path file = writePng('chart.png', 12, 8)
+        system.execute("/img ${forwardSlashes(file)}")
+        // Dumb terminal — none of Sixel/Kitty/iTerm2 — falls through to the
+        // summary-line branch in img(). Assert on dimensions and the file
+        // identifier; don't pin to exact wording.
+        def out = printer.output.join()
+        assert out.contains('12x8')
+        assert out.contains('chart.png')
+    }
+
+    @Test
+    void missingFileSurfacesAClearError() {
+        // /img saves exceptions via saveException(); JLine's
+        // AbstractCommandRegistry.invoke rethrows them, so the user sees a
+        // clear error rather than a silent no-op.
+        def thrown = shouldFail(IllegalArgumentException) {
+            system.execute('/img no-such-file.png')
+        }
+        assert thrown.message.contains('File not found')
+        assert thrown.message.contains('no-such-file.png')
+    }
+
+    @Test
+    void imgFromBufferedImageVariable() {
+        // /img $var resolves the engine variable to its value via xargs.
+        BufferedImage src = new BufferedImage(20, 10, 
BufferedImage.TYPE_INT_RGB)
+        engine.put('imgVar', src)
+        system.execute('/img $imgVar')
+        def out = printer.output.join()
+        // Dumb terminal — falls through to the summary, which echoes the
+        // BufferedImage's actual dimensions.
+        assert out.contains('20x10')
+    }
+
+    @Test
+    void imgFromObjectWithCreateBufferedImage() {
+        // Duck-typed dispatch: anything with createBufferedImage(int,int) —
+        // mirrors JFreeChart's signature without us depending on JFreeChart.
+        engine.put('chart', new ChartLikeForTest())
+        system.execute('/img $chart --width=64 --height=32')
+        def out = printer.output.join()
+        assert out.contains('64x32')
+    }
+
+    @Test
+    void imgFromObjectWithToBufferedImage() {
+        // Sibling duck-type: anything with toBufferedImage(int,int) —
+        // mirrors Smile Figure's signature without us depending on Smile.
+        engine.put('figure', new FigureLikeForTest())
+        system.execute('/img $figure --width=72 --height=48')
+        def out = printer.output.join()
+        assert out.contains('72x48')
+    }
+
+    @Test
+    void imgFromJComponent() {
+        // JComponent path — laid out and painted into a BufferedImage at the
+        // requested size.
+        engine.put('label', new JLabel('hello'))
+        system.execute('/img $label --width=80 --height=24')
+        def out = printer.output.join()
+        assert out.contains('80x24')
+    }
+
+    @Test
+    void imgFromUnsupportedTypeErrors() {
+        engine.put('thing', 42)
+        def thrown = shouldFail(IllegalArgumentException) {
+            system.execute('/img $thing')
+        }
+        assert thrown.message.contains("don't know how to render")
+        assert thrown.message.contains('Integer')
+    }
+
+    @Test
+    void undefinedVariableProducesClearError() {
+        // $panel is not defined — JLine resolves it to null and passes null 
into
+        // input.args(). Should surface as a friendly message, not a raw NPE on
+        // startsWith().
+        def thrown = shouldFail(IllegalArgumentException) {
+            system.execute('/img $panel')
+        }
+        assert thrown.message.contains('null')
+        assert !thrown.message.contains('startsWith')
+    }
+
+    @Test
+    void trailingWidthFlagWithoutValueProducesClearError() {
+        // `/img --width` (no value, no positional) used to walk past the end
+        // of args() and surface as an opaque ArrayIndexOutOfBoundsException
+        // via saveException. Should now be a targeted IllegalArgumentException
+        // naming the missing flag.
+        def thrown = shouldFail(IllegalArgumentException) {
+            system.execute('/img --width')
+        }
+        assert thrown.message.contains('--width')
+        assert thrown.message.contains('missing value')
+    }
+
+    /** A test stand-in for JFreeChart-shaped objects. */
+    static class ChartLikeForTest {
+        BufferedImage createBufferedImage(int w, int h) {
+            new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)
+        }
+    }
+
+    /** A test stand-in for Smile-Figure-shaped objects. */
+    static class FigureLikeForTest {
+        BufferedImage toBufferedImage(int w, int h) {
+            new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB)
+        }
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/RoundTripTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/RoundTripTest.groovy
new file mode 100644
index 0000000000..c30354935b
--- /dev/null
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/RoundTripTest.groovy
@@ -0,0 +1,63 @@
+/*
+ *  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.groovy.groovysh.commands
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+
+import java.nio.file.Path
+
+/**
+ * End-to-end test that drives several commands in sequence — the kind of
+ * cross-command regression no single per-command test can catch.
+ *
+ * <p>Distinct from {@link SaveLoadTest}, which exercises {@code /save} →
+ * {@code engine.reset()} → {@code /load} programmatically: this test
+ * routes the reset through the {@code /reset} command itself, so the
+ * full registry-dispatch path is exercised end-to-end.
+ */
+class RoundTripTest extends SystemTestSupport {
+
+    @TempDir
+    Path tmp
+
+    @Test
+    void buildSaveResetLoadRestoresState() {
+        system.execute('class Probe {}')
+        system.execute('def doubler(n) { n * 2 }')
+        system.execute('import java.time.LocalDate')
+
+        Path file = tmp.resolve('session.groovy')
+        system.execute("/save ${forwardSlashes(file)}")
+
+        // Reset via the registered /reset command (not engine.reset()).
+        // This is the bit SaveLoadTest doesn't cover.
+        system.execute('/reset')
+        assert engine.types.isEmpty()
+        assert engine.methodNames.isEmpty()
+        assert engine.imports.isEmpty()
+
+        system.execute("/load ${forwardSlashes(file)}")
+
+        assert engine.types.containsKey('Probe')
+        assert engine.methodNames.contains('doubler')
+        assert engine.imports.values().any { 
it.contains('java.time.LocalDate') }
+        assert engine.execute('doubler(21)') == 42
+    }
+}
diff --git 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
index da853d70ed..5310699410 100644
--- 
a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
+++ 
b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy
@@ -70,4 +70,60 @@ class GroovyEngineTest {
         engine.execute('import java.awt.Point')
         assert engine.imports.values().any { it.contains('java.awt.Point') }
     }
+
+    @Test
+    void resetClearsTrackedDefinitionsButLeavesBindingVarsForFreshExecutes() {
+        // /reset wipes types/methods/imports/snippet-tracked variables;
+        // it does NOT delete shared/binding variables (those are managed
+        // by the underlying ScriptEngine and survive). This is contract
+        // users rely on.
+        engine.execute('class C {}')
+        engine.execute('def m(x) { x * 3 }')
+        engine.put('survivor', 'still here')
+
+        engine.reset()
+
+        assert engine.types.isEmpty()
+        assert engine.methodNames.isEmpty()
+        assert engine.imports.isEmpty()
+        assert engine.hasVariable('survivor')
+        assert engine.execute('survivor') == 'still here'
+    }
+
+    @Test
+    void redefiningATypeReplacesTheTrackedSnippet() {
+        // The user redefines a class (common in interactive use). The
+        // engine should keep only one snippet under that name and run
+        // the latest body — not stack two definitions and produce
+        // ambiguous behaviour.
+        engine.execute('class T { String greet() { "first" } }')
+        engine.execute('class T { String greet() { "second" } }')
+        assert engine.types.containsKey('T')
+        assert engine.execute('new T().greet()') == 'second'
+    }
+
+    @Test
+    void removeMethodDropsItFromTracking() {
+        engine.execute('def disposable() { 1 }')
+        assert engine.methodNames.contains('disposable')
+        engine.removeMethod('disposable')
+        assert !engine.methodNames.contains('disposable')
+    }
+
+    @Test
+    void removeTypeDropsItFromTracking() {
+        engine.execute('class Disposable {}')
+        assert engine.types.containsKey('Disposable')
+        engine.removeType('Disposable')
+        assert !engine.types.containsKey('Disposable')
+    }
+
+    @Test
+    void closureBindingVariableSurvivesAcrossExecutes() {
+        // A closure stored in the binding can be invoked by name on a
+        // later execute — useful for "save a callback, use it later"
+        // patterns that show up in REPL workflows.
+        engine.execute('greet = { name -> "hi, $name" }')
+        assert engine.execute("greet('paul')") == 'hi, paul'
+    }
 }

Reply via email to