Conversation
|
Hello there! I 100% agree with the motivation here. Long-running commands should give users useful feedback, and I have a strong bias toward rich terminal interfaces with readable output and color where it helps. My preference, though, would be to keep spinners and terminal styling, which recently started to go through a kind of a renaissance moment, in optional extensions, and invest in supported extension "hook" points in I've used I would like to preserve it as richer CLI features become available. Note For context, I recently published three independent gems that extend
Here is a recording of concurrent downloads with individual and overall progress bars, from And here is a quick screenshot:
And one more: https://asciinema.org/a/gBU8BS3KCRp97FX1 Building these has made me particularly interested in documented, supported ways to extend
A standalone spinner can already wrap a block without special framework support. The hooks would help where extensions need to participate in command execution or replace built-in behavior. I would favor introducing small interfaces for demonstrated needs rather than designing an elaborate plugin system up front. The Longer term, if the maintainers are interested, a curated list of recommended extensions could help users choose compatible, maintained gems without bringing every feature into the core. My gems currently have no Important My last point is on the importance of keeping the number of external dependencies as low as possible. Every new gem you bring into your application brings an entire sub-tree of new dependencies, each is a new attack vector, and with AI where it is today in terms of finding zero day exploits, anyone building serious applications can not afford not to think about the number of dependencies and whether this or that extension is worth the added risk. This is an argument perpendicular to both this PR and my point of view. Because on one hand my vote is to keep dry-cli with as few dependencies as possible, and yet have the ability to expand and extend it's functionality in various ways. I'd be happy to contribute focused PRs for those extension points and adapt my gems to use them. |
|
Another note is that your relatively simple spinner could be a meaningful addition to the dry-cli-ui gem, for when the progress bar or a multi-spinner may not be necessary. That said, the gem already includes the entire |
|
Hello, @kigster. I find your comments here perplexing for reasons I will get into.
You say you've been using dry-cli for years, but this strikes me as an odd characterization of it. That would be a fair characterization of dry-rb in general, but the design philosophy of this lib is not to be ultra-modular but in fact to be integrated and batteries-included. If you look at the gemspec for dry-cli you will note that it has zero dependencies. This is an important feature when considering its niche in the broader landscape. Nothing that dry-cli is doing is original; there is already broad overlap with ttytoolkit, the standard lib optparse, and the Thor library. It's also important to understand the history: originally, this was hanami-cli and it was built to perform the work of Hanami CLI utilities. So there are dual aspects to this library's design: as a foundation for Hanakai framework CLIs, and as a general-purpose toolkit. There would be no point in bringing another Spinner UI into the world for purely speculative reasons. The problem is you're thinking entirely from the end-user perspective. Think about it from the framework library side of things. The purpose of adding a Spinner here is for us to use it. I'm not building this speculatively, I'm building it to serve a concrete purpose. That's why it belongs here. We're not going to force everyone who uses a Hanakai CLI tool to pull in a half-dozen transitive dependencies just for some TUI niceties. We also have no intention of turning dry-cli into BubbleTea. But it's reasonable to want to raise the ceiling of what we can achieve without adding a dependency. If you want a loosely-coupled component system, ttytoolkit is an okay option. I've been using it for years, although pastel's lack of 256/truecolor is a pretty significant drawback. So the new
You're lobbying for extension hooks, I get it. That doesn't belong here. No part of this PR depends on private internal API. My implementation does provide extension hooks. So what was the point of raising this? If we depended on ttytoolkit as a transitive dependency, it is no less a maintenance burden and in fact it would be a larger one. That toolkit does more than we need, and its codebase is largely stagnant. That's a liability. |
|
@kigster Just to add: please stop using our GitHub issues as a place to advertise your extension gems. You've already shared these on our forum, that's enough. |
timriley
left a comment
There was a problem hiding this comment.
This is fantastic work, @alassek, I love it! I agree with all your factoring choices – I think it's great to provide the individual pieces as accessible building blocks to CLI authors.
All my comments here are super super minor. Feel free to adjust as you see fit :) Thanks!
| CIVIS = "\e[?25l" | ||
| def self.hide_cursor = CIVIS |
There was a problem hiding this comment.
I think the "canonical constant + human-friendly method name" approach here is good. The constants align us to the underlying working function, and the method names more clearly describe what it actually does :) Great idea!
There was a problem hiding this comment.
I only implemented the minimum necessary to demonstrate the idea, so if we're agreed on this design then I'll flesh this out with more codes.
I didn't address this in my comments. Yes, I think it's worth the complexity. If we're going to have a built-in spinner, we should make it flexible, otherwise our users will look elsewhere and lose the benefit of our "zero deps" offering. What I also like about this is that the complexity is progressively disclosed. If you don't need an advanced spinner, your simpler usage of the spinner API isn't compromised. |
There are a handful of obscure ANSI sequences that people are likely to need, I think providing a nicer interface for this will make it easier to do what is needed.
This is a very simple event loop that yields a block on an interval until it's told to stop.
9d438f0 to
b707b58
Compare
A barebones spinner implementation that cycles through a set of frames
while a worker thread is running.
```ruby
Dry::CLI::Spinner::Dot.run("%{spinner} waiting...") { sleep 5 }
```
If you need more control, you can provide a block argument to register
`before_tick` and `after_tick` handlers:
```ruby
include Dry::CLI::StyleMixin
template = style.bold.green["%{spinner}"]
template += style.italic.dim[" waiting "]
template += style.bold.white["%{num}"]
template += style.italic.dim[" seconds"]
Dry::CLI::Spinner::Dot.run template do |s|
trailer = Dry::CLI::Spinner::Ellipsis.frames.cycle
start = Time.now
s.before_tick do |_stream, data|
data[:num] = (Time.now - start).ceil
end
s.after_tick do |stream|
stream << style.italic.dim[trailer.next]
end
s.run { sleep 6 }
end
```

A very common thing CLI interfaces need to do is wait for something. It's very helpful to be able to display some sort of indication that you're working and not frozen while this happens.
I started designing the API from the outside-in on first-principles, before looking at other implementations like
TTY::Spinnerand BubbleTea, but we're all doing similar things so there are similarities. I have used both in the past.I consider this an optional enhancement that not every CLI will make us of, so I'm gating it with
autoload.Dry::CLI::ANSIShell tricks like spinners require a lot of ANSI control characters. This made me think we should have a public interface that makes this easier, akin to
Hanami::Http::Status.Q:
Dry::CLI::TickerThis runs a block on an interval using a very simple threaded implementation. Anything that needs to be animated in some way could be built on this, so I made it separate from Spinner itself.
Basic Spinner
The most basic approach is running a worker thread with a static message
When given a block with zero-arity, the block itself is the background task.
Advanced Spinner
I wanted the ability to do more complicated things, like changing the message as you wait for a countdown/countup.
Passing a one-arity block provides a
SpinnerDSLobject for registering callbacks.before_tickruns before the tick is rendered, providing access to both the stream and the formatting data hashafter_tickruns after the tick has been rendered, providing access to the streamrunis the background taskQ: Is this worth the complexity? My intention is to make it possible to write something like the download progress spinner in Homebrew.
Extensibility
I don't think it's very important to provide a long list of different spinner styles. The process of creating your own is very easy:
The actual sequencing is just
Enumerable#cycle.AI Disclosure
The majority of the implemention was written by me, but I made extensive use of DeepSeek to validate the error-handling of the threading, and build out test specs.