Terminal User Interface framework for Java that ports the Charmbracelet ecosystem (Bubble Tea, Bubbles, Lipgloss, Harmonica) from Go, enabling developers to build interactive CLI applications using The Elm Architecture pattern.
The Bubble Tea framework is the core of TUI4J, providing an event-driven architecture based on The Elm Architecture pattern. It manages the application lifecycle through a Model-Update-View cycle with Commands for side effects.
The Program class is the main entry point that manages the event loop and program execution.
package com.williamcallahan.tui4j.compat.bubbletea;
class Program {
// Constructors
Program(Model initialModel);
Program(Model initialModel, ProgramOption... options);
// Execution
void run();
Model runWithFinalModel();
// Runtime control
void send(Message msg);
boolean isRunning();
void waitForInit();
// Runtime control (continued)
// ... other methods
}Program options are created using static factory methods on the ProgramOption interface.
package com.williamcallahan.tui4j.compat.bubbletea;
interface ProgramOption {
void apply(Program program);
// Static factory methods for options
static ProgramOption withAltScreen();
static ProgramOption withReportFocus();
static ProgramOption withKittyKeyboard();
static ProgramOption withMouseAllMotion();
static ProgramOption withMouseCellMotion();
static ProgramOption withMouseClicks(int maxClickCount);
static ProgramOption withInput(InputStream input);
static ProgramOption withOutput(OutputStream output);
static ProgramOption withInputTTY();
static ProgramOption withContext(CompletableFuture<?> cancelSignal);
static ProgramOption withFilter(BiFunction<Model, Message, Message> filter);
static ProgramOption withFps(int fps);
static ProgramOption withoutSignalHandler();
static ProgramOption withoutCatchPanics();
static ProgramOption withoutSignals();
static ProgramOption withoutBracketedPaste();
static ProgramOption withoutRenderer();
}Usage:
// Simple program
Model model = new MyModel();
Program program = new Program(model);
program.run();
// Program with options
Program program = new Program(
model,
ProgramOption.withAltScreen(),
ProgramOption.withMouseCellMotion()
);
program.run();
// Send message from another thread
program.send(new CustomMessage());
// Run and get final model state
Model finalModel = program.runWithFinalModel();Program Options:
withAltScreen() - Use alternate screen buffer (preserves terminal content)withReportFocus() - Report window focus/blur eventswithKittyKeyboard() - Enable Kitty keyboard protocolwithMouseAllMotion() - Track all mouse motionwithMouseCellMotion() - Track mouse motion when button pressedwithMouseClicks(n) - Enable click detection (1=single, 2=double)The Model interface defines application state and behavior.
package com.williamcallahan.tui4j.compat.bubbletea;
interface Model {
// Initialize model, return initial command
Command init();
// Handle messages, return new model and command
UpdateResult<? extends Model> update(Message msg);
// Render current state to string
String view();
}Implementation Example:
class CounterModel implements Model {
private final int count;
public CounterModel(int count) {
this.count = count;
}
@Override
public Command init() {
return Command.none();
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof KeyPressMessage keyMsg) {
Key key = keyMsg.key();
if (key.type() == KeyType.KeyRunes && key.runes()[0] == '+') {
return UpdateResult.from(new CounterModel(count + 1));
}
if (key.type() == KeyType.KeyRunes && key.runes()[0] == '-') {
return UpdateResult.from(new CounterModel(count - 1));
}
if (key.type() == KeyType.KeyRunes && key.runes()[0] == 'q') {
return UpdateResult.from(this, Command.quit());
}
}
return UpdateResult.from(this);
}
@Override
public String view() {
return "Count: " + count + "\n\n" +
"Press + to increment, - to decrement, q to quit";
}
}Commands represent side effects and asynchronous operations.
package com.williamcallahan.tui4j.compat.bubbletea;
interface Command {
// Execute command and return message
Message execute();
// Control flow
static Command none();
static boolean isNone(Command cmd);
static Command quit();
// Batching and sequencing
static Command batch(Command... commands);
static Command batch(Collection<Command> commands);
static Command sequence(Command... commands);
static Command sequentially(Command... commands);
// Timing
static Command tick(Duration duration, Function<Object, Message> fn);
static Command every(Duration interval, Function<Object, Message> fn);
// Terminal operations
static Command println(Object... args);
static Command printf(String format, Object... args);
static Command setWindowTitle(String title);
static Command clearScreen();
static Command checkWindowSize();
// Mouse cursor
static Command setMouseCursorText();
static Command setMouseCursorPointer();
static Command resetMouseCursor();
// Mouse tracking
static Command enableMouseCellMotion();
static Command enableMouseAllMotion();
static Command disableMouse();
// Clipboard
static Command copyToClipboard(String text);
static Command paste();
// Bracketed paste
static Command enableBracketedPaste();
static Command disableBracketedPaste();
// System integration
static Command openUrl(String url);
static Command execProcess(
Process process,
BiConsumer<String, Process> onOutput,
BiConsumer<Integer, Process> onComplete
);
}Command Examples:
// Delay and execute
Command.tick(Duration.ofSeconds(2), id -> new TimeoutMessage())
// Batch multiple commands
Command.batch(
Command.setWindowTitle("My App"),
Command.enableMouseCellMotion(),
Command.tick(Duration.ofSeconds(1), id -> new TickMessage())
)
// Execute commands sequentially
Command.sequence(
Command.println("Starting..."),
Command.tick(Duration.ofMillis(500), id -> new InitializedMessage()),
Command.println("Done!")
)
// Open URL in browser
Command.openUrl("https://example.com")
// Copy to clipboard
Command.copyToClipboard("Hello, clipboard!")
// Execute external process
Process process = new ProcessBuilder("ls", "-la").start();
Command.execProcess(
process,
(output, proc) -> System.out.println(output),
(exitCode, proc) -> System.out.println("Exit: " + exitCode)
)The Message interface is a marker interface for all messages in the system.
package com.williamcallahan.tui4j.compat.bubbletea;
interface Message {
// Marker interface - no methods
}All messages implement this interface. Common message types:
KeyPressMessage - Keyboard inputMouseMessage - Mouse eventsWindowSizeMessage - Terminal resizeQuitMessage - Program quit requestErrorMessage - Error occurredResult type containing the new model and optional command.
package com.williamcallahan.tui4j.compat.bubbletea;
record UpdateResult<M extends Model>(M model, Command command) {
// Factory methods
static <M extends Model> UpdateResult<M> from(M model, Command cmd);
static <M extends Model> UpdateResult<M> from(M model);
}Usage:
// Return model with command
return UpdateResult.from(newModel, Command.quit());
// Return model without command (equivalent to Command.none())
return UpdateResult.from(newModel);
// Return unchanged model
return UpdateResult.from(this);Keyboard input message.
package com.williamcallahan.tui4j.compat.bubbletea;
class KeyPressMessage implements Message {
Key key();
}Terminal window resize event.
package com.williamcallahan.tui4j.compat.bubbletea;
record WindowSizeMessage(int width, int height) implements Message { }Usage:
if (msg instanceof WindowSizeMessage size) {
int width = size.width();
int height = size.height();
return UpdateResult.from(this.withSize(width, height));
}Request to quit the program.
package com.williamcallahan.tui4j.compat.bubbletea;
record QuitMessage() implements Message { }Error occurred during command execution.
package com.williamcallahan.tui4j.compat.bubbletea;
record ErrorMessage(Throwable error) implements Message { }Usage:
if (msg instanceof ErrorMessage err) {
Throwable exception = err.error();
return UpdateResult.from(this.withError(exception.getMessage()));
}Window focus events (requires withReportFocus() option).
package com.williamcallahan.tui4j.compat.bubbletea;
record FocusMessage(boolean focused) implements Message { }
record BlurMessage(boolean blurred) implements Message { }Clipboard paste event.
package com.williamcallahan.tui4j.compat.bubbletea;
record PasteMessage(String text) implements Message { }Usage:
if (msg instanceof PasteMessage paste) {
String text = paste.text();
return UpdateResult.from(this.withPastedText(text));
}Messages for controlling mouse behavior.
package com.williamcallahan.tui4j.compat.bubbletea;
record EnableMouseCellMotionMessage() implements Message { }
record EnableMouseAllMotionMessage() implements Message { }
record DisableMouseMessage() implements Message { }package com.williamcallahan.tui4j.compat.bubbletea;
record ReadClipboardMessage(String contents) implements Message { }package com.williamcallahan.tui4j.compat.bubbletea;
record ExecProcessMessage(
Process process,
BiConsumer<String, Process> onOutput,
BiConsumer<Integer, Process> onComplete
) implements Message { }
record ExecCompletedMessage(int exitCode, Process process) implements Message { }package com.williamcallahan.tui4j.compat.bubbletea;
record ClearScreenMessage() implements Message { }
record PrintLineMessage(String text) implements Message { }
record SetWindowTitleMessage(String title) implements Message { }
record OpenUrlMessage(String url) implements Message { }
record BatchMessage(List<Message> messages) implements Message { }
record SequenceMessage(List<Message> messages) implements Message { }
record EnterAltScreenMessage() implements Message { }
record ExitAltScreenMessage() implements Message { }
record EnableBracketedPasteMessage() implements Message { }
record DisableBracketedPasteMessage() implements Message { }
record SuspendMessage() implements Message { }
record ResumeMessage() implements Message { }
record UnknownInputByteMessage(byte b) implements Message { }package com.williamcallahan.tui4j.message;
record EnterKeyModifierMessage(EnterKeyModifier modifier) implements Message {
EnterKeyModifier modifier();
}
enum EnterKeyModifier {
Normal, Alt, Shift, Ctrl, CtrlShift
}Usage:
// Enable Kitty keyboard protocol for enhanced Enter key detection
Program program = new Program(
model,
Program.withKittyKeyboard()
);
// Handle Enter key modifiers in update()
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof EnterKeyModifierMessage enterMsg) {
EnterKeyModifier modifier = enterMsg.modifier();
switch (modifier) {
case Normal -> handleNormalEnter();
case Alt -> handleAltEnter();
case Shift -> handleShiftEnter();
case Ctrl -> handleCtrlEnter();
case CtrlShift -> handleCtrlShiftEnter();
}
}
return UpdateResult.from(this);
}Models can be composed by delegating to sub-models:
class ParentModel implements Model {
private final ChildModel child;
private final String title;
@Override
public UpdateResult<? extends Model> update(Message msg) {
// Delegate to child
UpdateResult<ChildModel> childResult = child.update(msg);
// Create new parent with updated child
ParentModel newParent = new ParentModel(
childResult.model(),
this.title
);
// Return parent with child's command
return UpdateResult.from(newParent, childResult.command());
}
@Override
public String view() {
return title + "\n" + child.view();
}
}Use wrapper records to tag messages for routing:
record ComponentMessage(String componentId, Message inner) implements Message { }
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof ComponentMessage tagged) {
if ("input".equals(tagged.componentId())) {
// Route to input component
return updateInput(tagged.inner());
}
}
return UpdateResult.from(this);
}Use commands to load data asynchronously:
@Override
public Command init() {
// Start loading data
return Command.tick(Duration.ZERO, id -> {
try {
String data = loadData();
return new DataLoadedMessage(data);
} catch (Exception e) {
return new ErrorMessage(e);
}
});
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof DataLoadedMessage loaded) {
return UpdateResult.from(this.withData(loaded.data()));
}
return UpdateResult.from(this);
}Use Command.every() for periodic updates:
@Override
public Command init() {
// Update every second
return Command.every(
Duration.ofSeconds(1),
id -> new TickMessage()
);
}
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof TickMessage) {
return UpdateResult.from(
this.incrementCounter(),
Command.every(Duration.ofSeconds(1), id -> new TickMessage())
);
}
return UpdateResult.from(this);
}Use Command.batch() to execute multiple commands:
@Override
public UpdateResult<? extends Model> update(Message msg) {
if (msg instanceof StartMessage) {
Command cmd = Command.batch(
Command.setWindowTitle("Loading..."),
Command.tick(Duration.ofSeconds(1), id -> new LoadMessage()),
Command.println("Starting...")
);
return UpdateResult.from(this, cmd);
}
return UpdateResult.from(this);
}Install with Tessl CLI
npx tessl i tessl/maven-com-williamcallahan--tui4j