Skip to content

feature: Add wvlet.uni.cli package with Chalk, Spinner, and ProgressBar#297

Merged
xerial merged 2 commits intomainfrom
feature/cli-utilities
Jan 12, 2026
Merged

feature: Add wvlet.uni.cli package with Chalk, Spinner, and ProgressBar#297
xerial merged 2 commits intomainfrom
feature/cli-utilities

Conversation

@xerial
Copy link
Copy Markdown
Member

@xerial xerial commented Jan 12, 2026

Summary

Add CLI utilities package (wvlet.uni.cli) inspired by TypeScript's Chalk and ora libraries:

  • Chalk: Fluent terminal text styling API

    • 16 ANSI colors + 256 colors + RGB/TrueColor
    • Text modifiers: bold, italic, underline, strikethrough, dim, inverse
    • Method chaining: Chalk.red.bold("Error!")
    • String extensions: "text".red (via ChalkOps)
  • Spinner: Terminal spinner/progress indicator

    • Multiple animation styles (Dots, Line, Arrow, Circle, etc.)
    • Status methods: succeed(), fail(), warn(), info()
    • Platform-aware symbols (✔ ✖ ⚠ ℹ)
  • ProgressBar: Configurable progress bar

    • Preset styles: Default, Shaded, Block, Arrow, Classic, etc.
    • Customizable width, colors, and display options
  • Terminal: Cursor control and screen manipulation

  • Cross-platform: JVM, Scala.js (Node.js), and Scala Native

Usage Examples

import wvlet.uni.cli.*
import wvlet.uni.cli.ChalkOps.*

// Chalk
Chalk.red.bold("Error!")
"Success".green
Chalk.hex("#FF5733")("Custom")

// Spinner
val spinner = Spinner().start("Loading...")
spinner.succeed("Done!")

// Progress Bar
val bar = ProgressBar(total = 100).start()
bar.increment()
bar.finish()

Test plan

  • All 59 unit tests pass
  • JVM, JS, and Native modules compile
  • Code formatted with scalafmt

🤖 Generated with Claude Code

Add CLI utilities inspired by TypeScript's Chalk and ora libraries:

- Chalk: Fluent terminal text styling API with support for 16 ANSI colors,
  256 colors, RGB/TrueColor, and text modifiers (bold, italic, underline, etc.)
- Spinner: Terminal spinner/progress indicator with multiple animation styles
  and status symbols (success, fail, warn, info)
- ProgressBar: Configurable progress bar with preset styles
- Terminal: Cursor control and screen manipulation utilities
- Cross-platform: JVM, Scala.js (Node.js), and Scala Native support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the feature New feature label Jan 12, 2026
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @xerial, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the command-line interface capabilities by introducing a comprehensive set of utilities within the new wvlet.uni.cli package. It provides robust tools for terminal text styling, dynamic progress feedback through spinners and progress bars, and fundamental terminal control, ensuring a consistent and rich user experience across JVM, Scala.js, and Scala Native platforms.

Highlights

  • New CLI Utilities Package: Introduced a new wvlet.uni.cli package offering cross-platform command-line interface utilities for JVM, Scala.js (Node.js), and Scala Native environments.
  • Chalk Terminal Styling: Added Chalk for fluent terminal text styling, supporting 16 ANSI colors, 256 colors, RGB/TrueColor, and various text modifiers (bold, italic, underline, strikethrough, dim, inverse). String extension methods are also provided for convenience.
  • Spinner Progress Indicator: Implemented a Spinner utility for animated terminal progress indicators with multiple animation styles (e.g., Dots, Line, Arrow, Circle) and status methods (succeed, fail, warn, info). It includes platform-aware symbols for better display.
  • ProgressBar Component: Included a configurable ProgressBar with various preset styles (Default, Shaded, Block, Arrow, Classic) and options for customizing width, colors, prefix/suffix, and display of percentage/count.
  • Terminal Control and Environment Detection: Added Terminal utilities for cursor control and screen manipulation (clear, move cursor, hide/show cursor). A CliEnv abstraction provides platform-specific detection for color level, interactivity, OS, and terminal dimensions.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive and well-designed wvlet.uni.cli package for building interactive command-line interfaces. The cross-platform support for JVM, Scala.js, and Scala Native is a significant feature. The APIs for Chalk, Spinner, and ProgressBar are fluent and intuitive. The code is generally of high quality with good test coverage.

My review focuses on improving thread-safety in the JVM and Native implementations and enhancing code style and maintainability in a few areas. Specifically, there are potential race conditions in the progress bar implementation that should be addressed. I've also suggested refactoring some methods to use more idiomatic functional patterns in Scala, which will improve readability and robustness.

*/
private class JvmRunningProgressBar(config: ProgressBar) extends RunningProgressBar:
private val currentValue = new AtomicLong(0)
private var finished: Boolean = false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The finished flag is a mutable var and is accessed from multiple threads without synchronization (e.g., render can be called from any thread via update/increment, and finish/fail from another). This can lead to race conditions. To ensure thread safety, please use java.util.concurrent.atomic.AtomicBoolean. You will also need to update its usage from finished to finished.get() for reads and finished.set(true) for writes.

  private val finished = new java.util.concurrent.atomic.AtomicBoolean(false)

*/
private class NativeRunningProgressBar(config: ProgressBar) extends RunningProgressBar:
private val currentValue = new AtomicLong(0)
private var finished: Boolean = false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The finished flag is a mutable var and is accessed from multiple threads without synchronization. Scala Native supports multi-threading, so this can lead to race conditions. To ensure thread safety, please use java.util.concurrent.atomic.AtomicBoolean. You will also need to update its usage from finished to finished.get() for reads and finished.set(true) for writes.

  private val finished = new java.util.concurrent.atomic.AtomicBoolean(false)

Comment on lines +44 to +85
override def colorLevel: ColorLevel =
if isBrowser then
// Browsers use CSS styling, not ANSI codes
ColorLevel.None
else if isNode then
// Check NO_COLOR
if env("NO_COLOR").isDefined then
return ColorLevel.None

// Check FORCE_COLOR
env("FORCE_COLOR") match
case Some("0") =>
return ColorLevel.None
case Some("1") =>
return ColorLevel.Basic
case Some("2") =>
return ColorLevel.Ansi256
case Some("3") =>
return ColorLevel.TrueColor
case Some(_) =>
return ColorLevel.Basic
case None => // continue

// Check COLORTERM
env("COLORTERM") match
case Some("truecolor") | Some("24bit") =>
return ColorLevel.TrueColor
case _ => // continue

// Check TERM
val term = env("TERM").getOrElse("")
if term == "dumb" then
ColorLevel.None
else if term.endsWith("-256color") then
ColorLevel.Ansi256
else if term.nonEmpty then
ColorLevel.Basic
else
ColorLevel.None
else
ColorLevel.None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The colorLevel method uses multiple return statements, which is not idiomatic in Scala and can make control flow harder to follow. Consider refactoring this method into a single expression, for example by nesting if/else expressions, to improve readability and adhere to functional programming principles.

Comment on lines +63 to +65
private def hideCursor(): Unit =
if js.typeOf(g.process) != "undefined" then
g.process.stderr.write("\u001b[?25l")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The check js.typeOf(g.process) != "undefined" is repeated across several methods (hideCursor, showCursor, write). To improve code clarity and avoid redundant checks, consider defining a private val isNode = js.typeOf(g.process) != "undefined" at the class level and using it in these methods.

Comment on lines +39 to +80
if currentText.nonEmpty then
println(currentText)

override def text: String = currentText
override def text_=(newText: String): Unit = currentText = newText

override def succeed(text: String): Unit =
val finalText =
if text.nonEmpty then
text
else
currentText
println(s"${Symbols.successColored} ${finalText}")
running = false

override def fail(text: String): Unit =
val finalText =
if text.nonEmpty then
text
else
currentText
println(s"${Symbols.errorColored} ${finalText}")
running = false

override def warn(text: String): Unit =
val finalText =
if text.nonEmpty then
text
else
currentText
println(s"${Symbols.warningColored} ${finalText}")
running = false

override def info(text: String): Unit =
val finalText =
if text.nonEmpty then
text
else
currentText
println(s"${Symbols.infoColored} ${finalText}")
running = false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

DisabledSpinner uses println throughout, which writes to standard output (console.log in JS). UI elements like spinners should write to standard error to avoid mixing status updates with program output. For consistency with JsRunningSpinner and the JVM/Native implementations, please use config.stream.println instead of println in the constructor and in the succeed, fail, warn, and info methods to respect the configured stream (which defaults to System.err).

Comment on lines +109 to +111
private def hideCursor(): Unit =
if js.typeOf(g.process) != "undefined" then
g.process.stderr.write("\u001b[?25l")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The check js.typeOf(g.process) != "undefined" is repeated across several methods (hideCursor, showCursor, write). To improve code clarity and avoid redundant checks, consider defining a private val isNode = js.typeOf(g.process) != "undefined" at the class level and using it in these methods.

Comment on lines +22 to +75
override def colorLevel: ColorLevel =
// Check NO_COLOR first (https://no-color.org/)
if env("NO_COLOR").isDefined then
return ColorLevel.None

// Check FORCE_COLOR
env("FORCE_COLOR") match
case Some("0") =>
return ColorLevel.None
case Some("1") =>
return ColorLevel.Basic
case Some("2") =>
return ColorLevel.Ansi256
case Some("3") =>
return ColorLevel.TrueColor
case Some(_) =>
return ColorLevel.Basic
case None => // continue detection

// Check if not connected to a terminal
if !isInteractive then
return ColorLevel.None

// Check COLORTERM for true color support
env("COLORTERM") match
case Some("truecolor") | Some("24bit") =>
return ColorLevel.TrueColor
case _ => // continue

// Check TERM
val term = env("TERM").getOrElse("")

if term == "dumb" then
return ColorLevel.None

// Check for 256 color support
if term.endsWith("-256color") || term.endsWith("256color") then
return ColorLevel.Ansi256

// Check for true color support in terminal name
if term.contains("truecolor") || term.contains("24bit") then
return ColorLevel.TrueColor

// Windows 10+ has native ANSI support
if isWindows then
return ColorLevel.Basic

// Most modern terminals support at least basic colors
if term.nonEmpty then
ColorLevel.Basic
else
ColorLevel.None

end colorLevel
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The colorLevel method uses multiple return statements, which is not idiomatic in Scala. This can make the control flow harder to follow. Refactoring this to a single expression without return would make the code more functional and readable.

* Disabled spinner that just logs text without animation.
*/
private class DisabledSpinner(config: Spinner) extends RunningSpinner:
private var currentText: String = config.text
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The currentText field is a var String, which is not thread-safe. Since text_= can be called from any thread, this could lead to visibility issues. Please use java.util.concurrent.atomic.AtomicReference[String] to ensure thread-safety. You will need to update all reads to currentText.get() and writes to currentText.set(newText).

  private val currentText = new java.util.concurrent.atomic.AtomicReference[String](config.text)

Comment on lines +22 to +60
override def colorLevel: ColorLevel =
// Check NO_COLOR
if env("NO_COLOR").isDefined then
return ColorLevel.None

// Check FORCE_COLOR
env("FORCE_COLOR") match
case Some("0") =>
return ColorLevel.None
case Some("1") =>
return ColorLevel.Basic
case Some("2") =>
return ColorLevel.Ansi256
case Some("3") =>
return ColorLevel.TrueColor
case Some(_) =>
return ColorLevel.Basic
case None => // continue

// Check COLORTERM
env("COLORTERM") match
case Some("truecolor") | Some("24bit") =>
return ColorLevel.TrueColor
case _ => // continue

// Check TERM
val term = env("TERM").getOrElse("")
if term == "dumb" then
ColorLevel.None
else if term.endsWith("-256color") || term.endsWith("256color") then
ColorLevel.Ansi256
else if term.contains("truecolor") || term.contains("24bit") then
ColorLevel.TrueColor
else if term.nonEmpty then
ColorLevel.Basic
else
ColorLevel.None

end colorLevel
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The colorLevel method uses multiple return statements, which is not idiomatic in Scala. This can make the control flow harder to follow. Refactoring this to a single expression without return would make the code more functional and readable.

* Disabled spinner that just logs text without animation.
*/
private class DisabledSpinner(config: Spinner) extends RunningSpinner:
private var currentText: String = config.text
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The currentText field is a var String, which is not thread-safe. Since text_= can be called from any thread and Scala Native supports multi-threading, this could lead to visibility issues. Please use java.util.concurrent.atomic.AtomicReference[String] to ensure thread-safety. You will need to update all reads to currentText.get() and writes to currentText.set(newText).

  private val currentText = new java.util.concurrent.atomic.AtomicReference[String](config.text)

- Use AtomicBoolean for `finished` field in JVM/Native RunningProgressBar
- Use AtomicReference for `currentText` in JVM/Native DisabledSpinner
- Use config.stream.println instead of println in JS DisabledSpinner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@xerial
Copy link
Copy Markdown
Member Author

xerial commented Jan 12, 2026

Thread-safety fixes addressed

Based on Gemini's code review, I've fixed the following thread-safety issues:

High Priority (Fixed)

  • JVM/Native RunningProgressBar: Changed finished: var Boolean to AtomicBoolean
  • JVM/Native DisabledSpinner: Changed currentText: var String to AtomicReference[String]

Medium Priority (Fixed)

  • JS DisabledSpinner: Changed println() to config.stream.println() for consistency

All changes have been pushed in commit 4d4726c.

@xerial xerial merged commit 528ac6b into main Jan 12, 2026
10 checks passed
@xerial xerial deleted the feature/cli-utilities branch January 12, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant