SPRING SHELL 4.0.3 · STYLING-REPORT

Styling & Highlighting mit spring-shell-jline

Was die Bibliothek in Version 4.0.3 anbietet — entwickelt entlang der Attachment-Ausgabe von ReadCommand.java aus tina-snow-cli.

Projekt
tina-snow-cli 1.0.1-SNAPSHOT
Spring Shell
4.0.3 (spring-shell-jline, spring-shell-core)
Spring Boot
4.1.0
JLine
3.30.13 (transitiv über spring-shell-jline)
Bezugsdatei
src/main/java/…/command/ReadCommand.java
Verifikation
javap gegen die JARs in ~/.m2 — keine Signatur aus dem Gedächtnis

ReadCommand färbt seine Ausgabe heute mit elf handgeschriebenen ANSI-Escape-Konstanten. Das funktioniert — und lässt genau die Fähigkeiten liegen, für die spring-shell-jline überhaupt als eigene Dependency in der pom.xml steht. Dieser Report zeigt fünf Ausbaustufen, jede als Before/After am gleichen Code.

ANSI-Konstanten heute
11Stk
Style-Klassen in spring-shell-core
0Stk
Style-Tags im Theme-System
18Tags
Tabellen-Rahmenstile
11Stile

Ausgangslage: elf Konstanten und ein String.format

Der aktuelle Stand ist ein Block privater Konstanten und ein String.format, das Farbe und Inhalt in derselben Zeile verschränkt. Lesbar ist das nur, solange man die Escape-Sequenzen im Kopf hat.

Before
/**
 * Optionally for coloring the output in the terminal. ANSI escape codes are used for text formatting and coloring.
 */
private static final String RESET      = "\u001B[0m";
private static final String BOLD       = "\u001B[1m";
private static final String DIM        = "\u001B[2m";
private static final String ITALIC     = "\u001B[3m";
private static final String UNDERLINE  = "\u001B[4m";

private static final String RED        = "\u001B[31m";
private static final String GREEN      = "\u001B[32m";
private static final String BLUE       = "\u001B[34m";
private static final String MAGENTA    = "\u001B[35m";
private static final String GRAY       = "\u001B[90m";
private static final String DARK_BLUE_256 = "\u001B[38;5;18m";
ReadCommand.java:24–38
Before — die Ausgabezeile
sb.append(String.format(
        "\n%s | %s | %s | %s | erstellt: %s | von: %s%n",
        BOLD + DARK_BLUE_256 + fileName + RESET,
        ITALIC + MAGENTA + reversedContentType + RESET,
        GREEN + sizeBytes + GRAY + " bytes",
        DIM + RED + sysId + RESET,
        UNDERLINE + BLUE + createdOn + RESET,
        BOLD + DARK_BLUE_256 + createdBy + RESET));
ReadCommand.java:63–72

Im Terminal sieht das so aus:

2024-Q3-Report.xlsx | xlsx | 184320 bytes | a1b2c3d4e5f6 | erstellt: 2026-08-14 09:12:03 | von: maier.a
  1. S1Wartung
    Die Farbwahl ist einkompiliert. Eine Farbanpassung ist ein Code-Change plus Build plus Deployment.
  2. S2Portabilität
    \u001B[38;5;18m setzt ein 256-Farb-Terminal voraus. Auf einem 16-Farben-TERM wird das nicht heruntergerechnet, sondern ignoriert oder falsch dargestellt.
  3. S3Pipe-Ausgabe
    Die Escapes werden bedingungslos geschrieben — auch wenn die Ausgabe in eine Datei oder durch grep läuft. Genau das ist im nicht-interaktiven CI/CD-Modus dieses Projekts der Normalfall.

Warum das JAR überhaupt separat in der pom.xml steht

Ein Blick in die beiden JARs erklärt die Dependency-Zeile: In spring-shell-core-4.0.3.jar liegt keine einzige Style-, Theme- oder Table-Klasse. Das komplette Vokabular ist in 4.0.x nach org.springframework.shell.jline.tui.* gewandert und wird nur von spring-shell-jline ausgeliefert.

BausteinVoll qualifizierter Name (4.0.3)JAR
Style-Auflösungo.s.shell.jline.tui.style.ThemeResolverjline
Style-Tags & Defaults…style.StyleSettingsjline
Template-Rendering…style.TemplateExecutorjline
Theme-Definition…style.Theme, ThemeSettings, ThemeRegistryjline
Sonderzeichen-Tags…style.FigureSettingsjline
Tabellen…tui.table.TableBuilder, TableModelBuilder, BorderStylejline
Eingabe-Highlightingo.s.shell.jline.CommandHighlighterjline
Bean-Verdrahtungo.s.shell.core.autoconfigure.ThemingAutoConfigurationcore-autoconfigure

Die Verdrahtung nimmt einem Spring Boot ab. ThemingAutoConfiguration stellt vier Beans bereit — man injiziert sie einfach in den Command:

// aus ThemingAutoConfiguration, per javap ausgelesen:
public ThemeActive      themeActive();
public ThemeRegistry    themeRegistry(ObjectProvider<Theme> themes);
public ThemeResolver    shellThemeResolver(ThemeRegistry, SpringShellProperties, ThemeActive);
public TemplateExecutor templateExecutor(ThemeResolver);
MERKE
themeRegistry sammelt seine Themes per ObjectProvider<Theme> ein. Ein eigenes Theme zu registrieren heißt deshalb: ein @Bean Theme deklarieren. Mehr nicht.

Stufe 1 — AttributedString statt Escape-Literalen

Die unterste Stufe tauscht nur die Schreibweise: AttributedStyle aus JLine beschreibt den Stil als Objekt, AttributedStringBuilder setzt Text und Stil zusammen. Der entscheidende Gewinn ist nicht Schönheit, sondern dass JLine beim Rendern weiß, was das Zielterminal kann.

Before
BOLD + DARK_BLUE_256 + fileName + RESET
After
AttributedStyle.DEFAULT
    .bold()
    .foreground(AttributedStyle.BLUE)
After — die ganze Zeile
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;

private static final AttributedStyle S_FILE    = AttributedStyle.DEFAULT.bold().foreground(AttributedStyle.BLUE);
private static final AttributedStyle S_TYPE    = AttributedStyle.DEFAULT.italic().foreground(AttributedStyle.MAGENTA);
private static final AttributedStyle S_SIZE    = AttributedStyle.DEFAULT.foreground(AttributedStyle.GREEN);
private static final AttributedStyle S_UNIT    = AttributedStyle.DEFAULT.faint();
private static final AttributedStyle S_SYSID   = AttributedStyle.DEFAULT.faint().foreground(AttributedStyle.RED);
private static final AttributedStyle S_CREATED = AttributedStyle.DEFAULT.underline().foreground(AttributedStyle.BLUE);

AttributedStringBuilder asb = new AttributedStringBuilder();
asb.styled(S_FILE, fileName)
   .append(" | ")
   .styled(S_TYPE, reversedContentType)
   .append(" | ")
   .styled(S_SIZE, sizeBytes)
   .styled(S_UNIT, " bytes")
   .append(" | ")
   .styled(S_SYSID, sysId)
   .append(" | erstellt: ")
   .styled(S_CREATED, createdOn)
   .append(" | von: ")
   .styled(S_FILE, createdBy);

return asb.toAnsi();   // NICHT toString() -- siehe Falle 1
  1. FALLE 1scheitert lautlos

    Ein @Command-Rückgabewert wird von MethodInvokerCommandAdapter mit PrintWriter.println(Object) ausgegeben — also über String.valueOf(…) und damit toString().

    AttributedString.toString() liefert den Klartext ohne jede Formatierung. Wer ein AttributedString direkt zurückgibt, bekommt eine korrekte, aber völlig farblose Ausgabe — ohne Fehlermeldung, ohne Warnung. Richtig ist .toAnsi() oder, terminal-bewusst, .toAnsi(terminal).

Weitere Nützlichkeiten von AttributedStyle / AttributedStringBuilder
// echte RGB-Farbe statt 256er-Index -- JLine rechnet auf die Terminal-Palette herunter
AttributedStyle.DEFAULT.foreground(0x1a, 0x3a, 0x8f);   // foreground(int r, int g, int b)
AttributedStyle.DEFAULT.foregroundRgb(0x1a3a8f);        // dieselbe Farbe als ein int

// helle Variante einer Basisfarbe
AttributedStyle.DEFAULT.foreground(AttributedStyle.CYAN | AttributedStyle.BRIGHT);

// weitere Attribute
.bold() .faint() .italic() .underline() .blink() .inverse() .conceal() .crossedOut()
.background(int) .backgroundRgb(int) .foregroundDefault() .boldOff()

// Regex-basiertes nachtraegliches Faerben -- z.B. Suchtreffer in der Ausgabe markieren
asb.styleMatches(Pattern.compile("(?i)" + Pattern.quote(filter)),
                 AttributedStyle.DEFAULT.inverse());

// terminal-bewusstes Rendern: JLine kennt Farbtiefe und Palette des Ziels
return asb.toAnsi(terminal);

Stufe 2 — ThemeResolver und benannte Style-Tags

Stufe 1 hat die Escapes ersetzt, aber die Farbwahl steckt weiter im Code. Stufe 2 gibt ihr einen Namen. StyleSettings definiert 18 Tags mit Default-Stil, ThemeResolver löst einen Tag zum konkreten Stil auf. Der Command sagt dann nicht mehr „fett und blau“, sondern „das ist ein Listenwert“.

KonstanteTag-NameDefault-Stil
TAG_TITLEstyle-titlebold
TAG_VALUEstyle-valuefg:blue
TAG_LIST_KEYstyle-list-keydefault
TAG_LIST_VALUEstyle-list-valuebold,fg:green
TAG_LEVEL_INFOstyle-level-infofg:green
TAG_LEVEL_WARNstyle-level-warnfg:yellow
TAG_LEVEL_ERRORstyle-level-errorfg:red
TAG_ITEM_ENABLEDstyle-item-enabledbold
TAG_ITEM_DISABLEDstyle-item-disabledfaint
TAG_ITEM_SELECTEDstyle-item-selectedfg:green
TAG_ITEM_UNSELECTEDstyle-item-unselectedbold
TAG_ITEM_SELECTORstyle-item-selectorbold,fg:bright-cyan
TAG_HIGHLIGHTstyle-highlightbold
TAG_BACKGROUNDstyle-backgrounddefault
… dazu style-dialog-background, style-button-background, style-menubar-background, style-statusbar-background — nur für die TUI-Views relevant

Die Stil-Sprache selbst ist die von JLine — kommagetrennte Attribute:

bold, faint, italic, underline, blink, inverse, conceal, crossed-out
fg:red   fg:bright-cyan   bg:black   default
After — Tag statt Farbe
import org.springframework.shell.jline.tui.style.StyleSettings;
import org.springframework.shell.jline.tui.style.ThemeResolver;

private final ThemeResolver themeResolver;   // Bean aus ThemingAutoConfiguration

public ReadCommand(AuthService authService,
                   ServiceNowService serviceNowService,
                   ThemeResolver themeResolver) {
    this.authService = authService;
    this.serviceNowService = serviceNowService;
    this.themeResolver = themeResolver;
}

// Tag -> Stil-Ausdruck ("bold,fg:green") -> AttributedStyle
AttributedStyle fileStyle = themeResolver.resolveStyle(
        themeResolver.resolveStyleTag(StyleSettings.TAG_LIST_VALUE));

AttributedStringBuilder asb = new AttributedStringBuilder();
asb.styled(fileStyle, fileName);

Bequemer ist der Ausdrucks-Modus. evaluateExpression reicht intern an JLines StyleExpression weiter — Syntax @{stil text}:

AttributedString line = themeResolver.evaluateExpression(
        "@{bold,fg:blue " + fileName + "} | " +
        "@{italic,fg:magenta " + reversedContentType + "} | " +
        "@{fg:green " + sizeBytes + "}@{faint  bytes}");

return line.toAnsi();
VORSICHT
evaluateExpression interpretiert @{…} im gesamten String. Dateinamen aus ServiceNow sind Fremddaten — enthält einer eine geschweifte Klammer, verschiebt sich die Auswertung. Für Werte aus dem Backend ist der Tag-Weg (resolveStyle + styled(…)) der sichere, der Ausdrucks-Weg gehört in Gerüst-Text mit bekanntem Inhalt.

Stufe 3 — TemplateExecutor: Layout aus dem Code holen

TemplateExecutor rendert StringTemplate-4-Templates und registriert dafür einen eigenen AttributeRenderer. Dessen Trick: ein format, das mit style- beginnt, wird über den ThemeResolver aufgelöst und der Wert anschließend in @{stil wert} verpackt. Das Layout wandert damit aus dem Java-Code in einen Template-String — und die Fremddaten laufen durch den Renderer statt durch den Ausdrucks-Parser.

Before
sb.append(String.format(
        "\n%s | %s | %s | %s | erstellt: %s | von: %s%n",
        BOLD + DARK_BLUE_256 + fileName + RESET, /* ... */ ));
After
import org.springframework.shell.jline.tui.style.TemplateExecutor;

private static final String ROW_TEMPLATE =
        "<fileName; format=\"style-list-value\"> | " +
        "<contentType; format=\"style-value\"> | " +
        "<size; format=\"style-level-info\"> bytes | " +
        "<sysId; format=\"style-item-disabled\"> | " +
        "erstellt: <createdOn; format=\"style-value\"> | " +
        "von: <createdBy; format=\"style-title\">";

private final TemplateExecutor templateExecutor;   // Bean aus ThemingAutoConfiguration

Map<String, Object> attrs = Map.of(
        "fileName",    fileName,
        "contentType", reversedContentType,
        "size",        sizeBytes,
        "sysId",       sysId,
        "createdOn",   createdOn,
        "createdBy",   createdBy);

return templateExecutor.render(ROW_TEMPLATE, attrs).toAnsi();
2024-Q3-Report.xlsx | xlsx | 184320 bytes | a1b2c3d4e5f6 | erstellt: 2026-08-14 09:12:03 | von: maier.a
Zwei Extras des Renderers

Kürzen. Neben style-* kennt der Renderer ein truncate--Format. Die Optionen werden mit - getrennt, Schlüssel und Wert mit :, erlaubt sind width und prefix (beides ganze Zahlen).

<fileName; format="truncate-width:24">
<sysId;    format="truncate-width:12-prefix:6">

Sonderzeichen. renderGroup erwartet eine ST-Gruppe mit einem Template namens main und stellt zusätzlich das Wörterbuch figures bereit — die Tags aus FigureSettings: tick, info, warning, error, checkbox-on/-off, Pfeile in alle vier Richtungen und question-mark.

Stufe 4 — Eigenes Theme, umschaltbar per application.yaml

Erst hier zahlt sich das Tag-System aus: Solange der Code nur Tags nennt, lässt sich die gesamte Farbgebung austauschen, ohne einen Command anzufassen. ThemeRegistry sammelt jedes Theme-Bean ein, spring.shell.theme.name wählt aus.

import org.springframework.shell.jline.tui.style.StyleSettings;
import org.springframework.shell.jline.tui.style.Theme;
import org.springframework.shell.jline.tui.style.ThemeSettings;

@Configuration
class ShellThemeConfig {

    @Bean
    Theme tinaTheme() {
        StyleSettings styles = new StyleSettings() {
            @Override public String listValue()      { return "bold,fg:cyan"; }
            @Override public String value()          { return "fg:bright-blue"; }
            @Override public String listLevelInfo()  { return "fg:green"; }
            @Override public String listLevelWarn()  { return "bold,fg:yellow"; }
            @Override public String listLevelError() { return "bold,fg:bright-red"; }
            @Override public String itemDisabled()   { return "faint"; }
        };
        return Theme.of("tina", new ThemeSettings(styles) { });
    }
}
spring:
  main:
    web-application-type: none
  shell:
    interactive:
      enabled: false   # Nicht-interaktiver Modus fuer CI/CD-Nutzung
    theme:
      name: tina       # -> ThemeRegistry.get("tina")
src/main/resources/application.yaml — ergänzt um den theme-Block
EINSCHUB
ThemeSettings und StyleSettings sind abstract, aber ohne abstrakte Methoden — jede Methode hat einen Default. Man überschreibt deshalb nur die Tags, die man wirklich anders will, der Rest bleibt beim Standard. StyleSettings.dump() und ThemeSettings.dump() liefern eine Variante, die statt der Stile die Tag-Namen zurückgibt — praktisch beim Debuggen, um zu sehen, welcher Tag wo greift.

Stufe 5 — TableBuilder statt Pipe-Zeichen

Die Attachment-Ausgabe ist inhaltlich eine Tabelle, die derzeit mit " | " nachgebaut wird — mit dem bekannten Effekt, dass Spalten bei unterschiedlich langen Dateinamen ausfransen. org.springframework.shell.jline.tui.table löst das inklusive Spaltenbreiten, Umbruch und Rahmen.

After
import org.springframework.shell.jline.tui.table.*;

TableModelBuilder<String> model = new TableModelBuilder<>();
model.addRow().addValue("Datei").addValue("Typ").addValue("Groesse")
     .addValue("sys_id").addValue("erstellt").addValue("von");

for (Map<String, Object> a : sortedAttachments) {
    model.addRow()
         .addValue((String) a.get("file_name"))
         .addValue(serviceNowService.getExtensionForContentType((String) a.get("content_type")))
         .addValue(String.valueOf(a.get("size_bytes")))
         .addValue((String) a.get("sys_id"))
         .addValue(String.valueOf(a.get("sys_created_on")))
         .addValue(String.valueOf(a.get("sys_created_by")));
}

Table table = new TableBuilder(model.build())
        .addHeaderBorder(BorderStyle.fancy_light)
        .on(CellMatchers.column(2)).addAligner(SimpleHorizontalAligner.right).and()
        .on(CellMatchers.column(0)).addSizer(new AbsoluteWidthSizeConstraints(32)).and()
        .build();

return table.render(120);   // 120 = verfuegbare Terminalbreite
Datei Typ Groesse sys_id erstellt von ──────────────────────────────────────────────────────────────────────────────────────────── 2024-Q3-Report.xlsx xlsx 184320 a1b2c3d4e5f6 2026-08-14 09:12:03 maier.a anlage-technische-details.csv csv 9218 f6e5d4c3b2a1 2026-08-13 17:40:55 system freigabe_final_v2.zip zip 2410992 0099aabbccdd 2026-08-11 08:03:17 maier.a
BereichVerfügbar in 4.0.3
Rahmenstile (BorderStyle) oldschool, air, fancy_light, fancy_heavy, fancy_double, dazu fancy_light und fancy_heavy je als _double_dash, _triple_dash, _quadruple_dash
Rahmen-Kurzformen addOutlineBorder, addHeaderBorder, addFullBorder, addInnerBorder, addHeaderAndVerticalsBorders, paintBorder(style, int)
Zellauswahl (CellMatchers) table(), row(int), column(int), at(int,int), ofType(Class)
Pro Zelle einstellbar addFormatter, addSizer, addWrapper, addAligner
Größe & Umbruch AutoSizeConstraints, AbsoluteWidthSizeConstraints, NoWrapSizeConstraints, KeyValueSizeConstraints, DelimiterTextWrapper, KeyValueTextWrapper
Modelle TableModelBuilder, ArrayTableModel, BeanListTableModel
KOMBINIERBAR
Tabelle und Stil schließen sich nicht aus: Ein eigener Formatter darf pro Zelle einen bereits gefärbten String liefern (themeResolver.evaluateExpression(…).toAnsi()). Die Spaltenbreite berechnet Table dann allerdings inklusive der Escape-Zeichen — bei gefärbten Zellen deshalb AbsoluteWidthSizeConstraints setzen, statt die Auto-Breite raten zu lassen.

Bonus — Prompt und Eingabe-Highlighting

Zwei Stellen färbt spring-shell-jline nicht in der Ausgabe, sondern in der Eingabezeile. Beide greifen ausschließlich im interaktiven Modus — in diesem Projekt steht spring.shell.interactive.enabled: false, sie sind hier also der Vollständigkeit halber genannt.

// eigener Prompt: ein Bean, das AttributedString liefert
@Bean
PromptProvider tinaPrompt(ThemeResolver themeResolver) {
    return () -> themeResolver.evaluateExpression("@{bold,fg:cyan tina-snow}@{faint  > }");
}

CommandHighlighter implementiert JLines Highlighter und färbt beim Tippen den erkannten Command-Namen gegen die CommandRegistry ein. Es ist bereits verdrahtet — ersetzen lässt es sich durch ein eigenes Highlighter-Bean.

tina-snow > read --article-number KB0012345 -A

Entscheidungshilfe

StufeAufwandLohnt sich, wenn…Verzichtbar, wenn…
1 AttributedString gering … die Ausgabe auf verschiedenen Terminals landen kann. JLine rechnet Farben auf die Zielpalette herunter. … nur ein bekanntes Terminal im Spiel ist.
2 ThemeResolver gering … mehrere Commands entstehen und einheitlich aussehen sollen. … es bei einem einzelnen Command bleibt.
3 TemplateExecutor mittel … das Ausgabeformat sich öfter ändert als die Logik, oder Fremddaten sicher eingebettet werden müssen. … das Format seit Monaten stabil ist.
4 Eigenes Theme mittel … Farben pro Umgebung oder Nutzer umschaltbar sein sollen (dev/prod, hell/dunkel). … niemand die Farbgebung je anfassen wird. Setzt Stufe 2 voraus.
5 TableBuilder gering … wie hier mehrere gleichförmige Datensätze ausgegeben werden. Größter sichtbarer Gewinn pro Zeile Code. … die Ausgabe eine einzelne Statusmeldung ist.
  1. E1Empfehlung
    Für ReadCommand konkret: Stufe 5 plus Stufe 2. Die Attachment-Liste ist eine Tabelle, und ein TableBuilder löst das Spaltenproblem, das reine Farbgebung gar nicht adressiert. Die Stufen 3 und 4 sind erst dann dran, wenn ein zweiter Command dieselbe Formatierung braucht.
  2. E2offen
    Keine der fünf Stufen löst S3 aus der Ausgangslage von allein: Auch toAnsi() ohne Terminal-Argument schreibt Escapes bedingungslos. Im nicht-interaktiven Modus dieses Projekts ist das der Regelfall. Die Variante toAnsi(Terminal) behält die Terminal-Fähigkeiten im Blick — ob sie bei umgeleiteter Ausgabe vollständig auf Klartext schaltet, ist für diesen Report nicht verifiziert und gehört vor einer Umstellung gegen die CI-Pipeline getestet.

Anhang: Verifikations-Log

Jede Signatur, jeder Tag-Name und jeder Default-Stil in diesem Report stammt aus dem Bytecode der lokal aufgelösten JARs, nicht aus der Dokumentation. Zum Nachvollziehen:

M2=~/.m2/repository/org/springframework/shell
J=$M2/spring-shell-jline/4.0.3/spring-shell-jline-4.0.3.jar

# Welche Style-Klassen liefert das jline-JAR ueberhaupt?
unzip -l $J | grep "tui/style/"

# Tag-Namen als Konstantenwerte
javap -constants -classpath $J org.springframework.shell.jline.tui.style.StyleSettings

# Default-Stile ("bold", "fg:green", ...) stehen im Bytecode der Basisklasse
javap -c -p -classpath $J org.springframework.shell.jline.tui.style.StyleSettings

# Beweis, dass core KEINE Style-Klasse enthaelt
unzip -l $M2/spring-shell-core/4.0.3/spring-shell-core-4.0.3.jar | grep -Ei "style|theme|table"

# Die Beans, die das alles verdrahten
javap -classpath $M2/spring-shell-core-autoconfigure/4.0.3/spring-shell-core-autoconfigure-4.0.3.jar \
      org.springframework.shell.core.autoconfigure.ThemingAutoConfiguration

# Falle 1: wie der Rueckgabewert eines @Command ausgegeben wird
javap -c -p org/springframework/shell/core/command/adapter/MethodInvokerCommandAdapter.class | grep PrintWriter
GELTUNGSBEREICH
Der Report beschreibt spring-shell-jline 4.0.3 mit JLine 3.30.13. Die Paketpfade (org.springframework.shell.jline.tui.style.*) unterscheiden sich von Spring Shell 3.x, wo dieselben Klassen als org.springframework.shell.style.* im core-Artefakt lagen. Snippets aus 3.x-Beispielen im Netz kompilieren hier nicht. Die Snippets dieses Reports wurden gegen die JAR-Signaturen geprüft, aber nicht kompiliert.

Zielbild — dieselbe Liste, zwei business-taugliche Ausgaben

Die Mockups oben zeigen jede Stufe isoliert. So sieht die Attachment-Liste aus, wenn die Bausteine mit Blick auf Lesbarkeit zusammengesetzt werden: die menschliche Größe statt der Byte-Zahl, ein relatives Datum, die sys_id gekürzt und zurückgenommen. Beide Varianten arbeiten auf denselben drei Datensätzen — einmal als eine Zeile pro Datei (Geist von Stufe 1), einmal als gerahmte Tabelle (Stufe 3 fürs Einfärben, Stufe 5 für den Rahmen).

Variante A — Zeile pro Datei
KB0012345 › 3 Anhänge 2,5 MB gesamt 2024-Q3-Report.xlsx xlsx 180 KB vor 13 Tagen maier.a ·a1b2c3 anlage-technische-details.csv csv 9 KB vor 14 Tagen system ·f6e5d4 freigabe_final_v2.zip zip 2,3 MB vor 16 Tagen maier.a ·0099aa
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;

// Stil einmal als Objekt -- JLine rechnet beim Rendern auf die Terminal-Palette herunter
private static final AttributedStyle NAME = AttributedStyle.DEFAULT.bold().foreground(AttributedStyle.BLUE);
private static final AttributedStyle META = AttributedStyle.DEFAULT.faint();
private static final AttributedStyle SIZE = AttributedStyle.DEFAULT.foreground(AttributedStyle.GREEN);

AttributedStringBuilder asb = new AttributedStringBuilder();
asb.styled(META, "%s \u203a %d Anhaenge".formatted(articleNumber, rows.size())).append("\n");

for (Attachment a : rows) {                       // rows: bereits nach Datum sortiert
    asb.styled(NAME, rpad(a.fileName(), 29))
       .styled(META, rpad(a.type(), 5))
       .styled(SIZE, lpad(humanSize(a.sizeBytes()), 7)).append("  ")
       .styled(META, rpad(relativeDate(a.createdOn()), 13))
       .append(rpad(a.createdBy(), 8))
       .styled(META, "\u00b7" + a.sysId().substring(0, 6))
       .append("\n");
}
return asb.toAnsi(terminal);                      // NICHT toString() -- Falle 1
rpad/lpad, humanSize, relativeDate sind Projekt-Helfer, keine Library-API. Die Farbe kommt ausschließlich aus AttributedStyle — siehe Stufe 1.
Variante B — gerahmte Tabelle
┌───────────────────────────────┬──────┬────────┬──────────────┬─────────┬─────────┐ │ Datei │ Typ │ Größe │ erstellt │ von │ sys_id │ ├───────────────────────────────┼──────┼────────┼──────────────┼─────────┼─────────┤ │ 2024-Q3-Report.xlsx │ xlsx │ 180 KB │ vor 13 Tagen │ maier.a │ ·a1b2c3 │ │ anlage-technische-details.csv │ csv │ 9 KB │ vor 14 Tagen │ system │ ·f6e5d4 │ │ freigabe_final_v2.zip │ zip │ 2,3 MB │ vor 16 Tagen │ maier.a │ ·0099aa │ └───────────────────────────────┴──────┴────────┴──────────────┴─────────┴─────────┘
import org.springframework.shell.jline.tui.table.*;
import org.springframework.shell.jline.tui.style.ThemeResolver;

private final ThemeResolver themeResolver;   // Bean aus ThemingAutoConfiguration

TableModelBuilder<String> model = new TableModelBuilder<>();
model.addRow().addValue("Datei").addValue("Typ").addValue("Groesse")
     .addValue("erstellt").addValue("von").addValue("sys_id");
for (Attachment a : rows) {
    model.addRow()
         .addValue(a.fileName())
         .addValue(a.type())
         .addValue(humanSize(a.sizeBytes()))        // 184320 -> "180 KB"
         .addValue(relativeDate(a.createdOn()))     // -> "vor 13 Tagen"
         .addValue(a.createdBy())
         .addValue("\u00b7" + a.sysId().substring(0, 6));
}

// Rahmen dezent (fancy_light), Dateiname pro Zelle ueber den ThemeResolver gefaerbt -- Stufe 3
Formatter accent = v -> new String[]{
        themeResolver.evaluateExpression("@{bold,fg:blue " + v + "}").toAnsi() };

Table table = new TableBuilder(model.build())
        .addFullBorder(BorderStyle.fancy_light)
        .on(CellMatchers.column(0)).addFormatter(accent)
              .addSizer(new AbsoluteWidthSizeConstraints(29)).and()   // Escapes zaehlen mit -> feste Breite
        .on(CellMatchers.column(2)).addAligner(SimpleHorizontalAligner.right).and()
        .build();
return table.render(84);
Der Rahmen ist bewusst gedimmt, damit die Daten vorn stehen. Die feste Spaltenbreite (AbsoluteWidthSizeConstraints) ist Pflicht, sobald eine Zelle ANSI-Escapes enthält — siehe die Karte KOMBINIERBAR bei Stufe 5.