Module: Agentilda::UI
- Included in:
- CLI::Agents::Describe, CLI::Agents::List, CLI::Base, Diagram
- Defined in:
- lib/agentilda/ui.rb
Overview
Everything the user sees that is not the deliverable itself.
Include it and you get info, warn, error and success as instance
methods, each drawing a TTY::Box on STDERR. STDERR is deliberate: the
documents and tables these commands produce own STDOUT, so every command
composes in a pipe.
Defined Under Namespace
Classes: Line
Constant Summary collapse
- MAX_WIDTH =
Widest a box may be drawn, regardless of how wide the terminal is.
100- MIN_WIDTH =
Narrowest, so a small terminal still produces readable boxes.
60- PROGRESS_THRESHOLD =
Below this many items a progress bar is noise: it appears and vanishes before the eye resolves it, and the line it prints is longer than the work.
3- NO_FIELDS =
What an item contributes to its log line's columns when its caller has nothing to say about it. A round header is such an item, and it still has to line up with the agent lines under it.
->(_item) { {} }
- NO_TIMEOUT =
What an item's countdown starts from when its caller has no opinion: nothing, so no timer is drawn. A line without a deadline showing 0:00 forever would read as an agent perpetually out of time.
->(_item) {}
- TIMER_WARNING =
The countdown turns red with this many seconds left — late enough to stay calm through a normal run, early enough to look up before the executor pulls the plug.
60- NO_FAILURE =
How a block's RETURN VALUE is judged when its caller has no opinion: nothing is ever a failure. The distinction exists because Runner's executor reports failure by returning a not-ok result rather than by raising — and a line that drew ✓ "done" over a timed-out agent, while the round table under it said FAIL, was the contradiction this closes.
->(_result) {}
- METER_WIDTH =
Cells the meter takes on a spinner line, per direction.
Fixed, and padded to it, because the numbers grow as the agent works and a column that sizes itself to them drags the whole line sideways every few seconds.
7- TIMER_WIDTH =
Cells the countdown takes,
MM:SSincluded — the run default of 900s reads15:00, and padding to a fixed width keeps the columns to its right from stepping sideways once9:59loses a digit. 6
Class Attribute Summary collapse
-
.log_path ⇒ String?
Where UI.log appends to, if anywhere.
-
.quiet ⇒ Boolean
Set by the CLI's --quiet.
Class Method Summary collapse
-
.abbreviate(count) ⇒ String
Token counts run to seven figures, and seven figures on a spinner line is four cells of noise about a number nobody reads to the digit.
-
.activity_for(spinner) ⇒ Proc
A callable that writes what an agent is doing onto its own spinner line.
-
.animate? ⇒ Boolean
Whether animated output is worth drawing at all.
-
.box(kind, message) ⇒ void
Everything the user sees goes through here, so it writes to
$stderrdirectly rather than throughKernel.warn. -
.box_height(text) ⇒ Integer
TTY::Box sizes itself from the number of lines you hand it, not from the number those lines occupy once wrapped to the box's width.
-
.color? ⇒ Boolean
Honours NO_COLOR — https://no-color.org.
-
.concurrently(items, message, jobs:, label: :to_s.to_proc, fields: NO_FIELDS, failure: NO_FAILURE, header: {}, timeout: NO_TIMEOUT) {|item| ... } ⇒ Array
Run a block over many items at once, one spinner each.
-
.countdown(left) ⇒ String
The countdown that sits between the spinner and the meter: what is left of the agent's timeout, quiet grey until the last TIMER_WARNING seconds, red from there down.
-
.default_jobs ⇒ Integer
A sensible worker count: agents are mostly waiting on a model rather than burning CPU, so this is deliberately close to the core count.
-
.display_width(text) ⇒ Integer
How many terminal cells the text occupies.
-
.elapsed(started) ⇒ String
E.g.
-
.fit(text, width) ⇒ String
Pad or truncate to an exact number of terminal cells.
-
.line(message, bullet: "·") ⇒ void
A single unadorned line, for per-item progress that does not deserve a box of its own.
-
.log(message, **fields) ⇒ void
Append one timestamped line to UI.log_path.
-
.logging_activity(text) ⇒ Proc
The same news, with no spinner to put it on.
-
.meter(update) ⇒ String
The token counter that sits between the spinner and the agent's name.
-
.monotonic ⇒ Float
A monotonic clock reading, immune to wall-clock changes.
-
.once(item, label, fields = NO_FIELDS, failure: NO_FAILURE, timeout: NO_TIMEOUT) {|item| ... } ⇒ Object
One item, no concurrency to speak of: a serial round (
--isolation shared), or the last plan left in a parallel one. -
.paint(text, *styles) ⇒ String
Decorated when colour is on, bare otherwise.
-
.pastel ⇒ Pastel
Colour engine, disabled when STDERR is not a terminal.
-
.popup(title, text) ⇒ void
A framed panel centered on the screen, for the keyboard help.
-
.report_line(text, bullet: "·") ⇒ void
A progress line, thread-safe and quiet-aware — the one thing every non-spinner path above needs and would otherwise have to reimplement.
-
.reset! ⇒ void
Forget everything memoized here.
-
.said(phrase) ⇒ String
The phrase as a spinner line carries it.
-
.solo_spinner(text) ⇒ TTY::Spinner
The spinner a lone agent gets.
-
.spinning(message) ⇒ Object
Indeterminate work — one call whose duration cannot be predicted, such as a network round trip.
-
.stepping(items, message) {|item| ... } ⇒ Array
Determinate work — N items of roughly equal cost.
-
.threaded(list, jobs, message, label: :to_s.to_proc, fields: NO_FIELDS, failure: NO_FAILURE) ⇒ Array
Parallelism with no spinner to draw: a pipe, a CI log, or a headless agent's own tool call.
-
.tty? ⇒ Boolean
Whether STDERR is an interactive terminal.
-
.width ⇒ Integer
A box width that fits the terminal but stays readable.
Instance Method Summary collapse
- #error(message) ⇒ void
- #info(message) ⇒ void
- #paint(text, *styles) ⇒ String
- #say(message, bullet: "·") ⇒ void
- #success(message) ⇒ void
- #warn(message) ⇒ void
Class Attribute Details
.log_path ⇒ String?
Where log appends to, if anywhere. nil (the default) means nowhere;
agentilda run sets this before the loop starts, so a round
started under a tool that discards STDERR — an agent's own Bash call,
for instance — still leaves something to tail -f.
213 214 215 |
# File 'lib/agentilda/ui.rb', line 213 def log_path @log_path end |
.quiet ⇒ Boolean
Set by the CLI's --quiet. Silences spinners and bars along with everything else, so a quiet run really is quiet.
188 189 190 |
# File 'lib/agentilda/ui.rb', line 188 def quiet @quiet end |
Class Method Details
.abbreviate(count) ⇒ String
Token counts run to seven figures, and seven figures on a spinner line is four cells of noise about a number nobody reads to the digit.
273 274 275 276 277 278 279 280 281 282 |
# File 'lib/agentilda/ui.rb', line 273 def abbreviate(count) count = count.to_i case count when 0...1_000 then count.to_s when 1_000...10_000 then "#{(count / 1000.0).round(1)}k" when 10_000...1_000_000 then "#{(count / 1000.0).round}k" when 1_000_000...10_000_000 then "#{(count / 1_000_000.0).round(1)}M" else "#{(count / 1_000_000.0).round}M" end end |
.activity_for(spinner) ⇒ Proc
A callable that writes what an agent is doing onto its own spinner line.
The :activity token is empty until something calls this, so a line
reads as it always did until there is news. It is written from the
reader thread the command's output arrives on, which is why the token
is replaced whole rather than appended to.
435 436 437 438 439 |
# File 'lib/agentilda/ui.rb', line 435 def activity_for(spinner) lambda { |phrase| spinner.update(activity: phrase.to_s.empty? ? "" : paint(": #{phrase}", :green, :bold)) } end |
.animate? ⇒ Boolean
Whether animated output is worth drawing at all. A pipe, a CI log or a --quiet run gets none: spinner frames written to a file are line noise.
295 |
# File 'lib/agentilda/ui.rb', line 295 def animate? = tty? && !quiet |
.box(kind, message) ⇒ void
This method returns an undefined value.
Everything the user sees goes through here, so it writes to $stderr
directly rather than through Kernel.warn.
That is not a style preference. Kernel.warn is a no-op when
$VERBOSE is nil, which is what -W0 sets — and RUBYOPT=-W0 is
common in CI images and agent harnesses. Routed through Kernel.warn,
every box this tool draws silently disappears in exactly the
environments where a failure most needs explaining.
standard:disable Style/StderrPuts -- the cop's own rationale, "to allow such output to be disabled", is the behaviour being removed here.
598 599 600 601 |
# File 'lib/agentilda/ui.rb', line 598 def box(kind, ) text = .to_s $stderr.puts TTY::Box.public_send(kind, text, enable_color: color?, width:, height: box_height(text)) end |
.box_height(text) ⇒ Integer
TTY::Box sizes itself from the number of lines you hand it, not from the number those lines occupy once wrapped to the box's width. So it draws any message containing a line longer than the box a row or two short, and what falls off is the bottom, which is where the instruction lives. The run that found this reported four of its ten failures and cut the fifth mid-sentence.
This wraps with the same library TTY::Box wraps with rather than dividing by the width, because TTY::Box wraps on words. A rough estimate is wrong in exactly the cases this exists for.
641 |
# File 'lib/agentilda/ui.rb', line 641 def box_height(text) = Strings.wrap(text.to_s, width - 4).lines.size + 3 |
.color? ⇒ Boolean
Honours NO_COLOR — https://no-color.org
552 |
# File 'lib/agentilda/ui.rb', line 552 def color? = tty? && !ENV.key?("NO_COLOR") |
.concurrently(items, message, jobs:, label: :to_s.to_proc, fields: NO_FIELDS, failure: NO_FAILURE, header: {}, timeout: NO_TIMEOUT) {|item| ... } ⇒ Array
Run a block over many items at once, one spinner each.
This is the shape for work that is independent and slow: each item gets its own line, its own thread and its own success or failure mark, so a long round reads as progress rather than as a hang.
Results come back in the order the items were given, not the order they finished — a caller that had to re-sort them would be a caller that eventually forgets to.
Every path here — one item, several without a terminal, several with
one — reports something. jobs <= 1 || list.size <= 1 used to bypass
all of it and run silently, which is exactly the shape a --plan NNN.MM round takes: one plan, one agent, nothing printed until the
whole thing finished and it was too late to tell "working" from "hung."
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 |
# File 'lib/agentilda/ui.rb', line 372 def concurrently(items, , jobs:, label: :to_s.to_proc, fields: NO_FIELDS, failure: NO_FAILURE, header: {}, timeout: NO_TIMEOUT, &block) list = items.to_a return [] if list.empty? log(, **header) if jobs <= 1 || list.size <= 1 report_line() unless animate? return list.map { |item| once(item, label, fields, failure:, timeout:, &block) } end return threaded(list, jobs, , label:, fields:, failure:, &block) unless animate? results = Concurrent::Hash.new spinners = TTY::Spinner::Multi.new( ":spinner #{paint(, :bold)}", format: :dots, output: $stderr, success_mark: paint("✓", :green), error_mark: paint("✖", :red) ) list.each_with_index do |item, index| text = label.call(item) child = spinners.register("[:spinner] :timer:meter#{text}:pid:activity") do |spinner| line = Line.new(fields: fields.call(item), spinner:, timeout: timeout.call(item)) line.start result = results[index] = block.call(item, line) if (reason = failure.call(result)) line.failed(reason) else line.done end rescue => e results[index] = e line&.failed(e..lines.first.to_s.strip) end # An unset token renders as the literal `:activity`, so every line # says so until its agent gets far enough to have news. The meter # starts at zero for the same reason, and because a counter that # appears once the first number arrives shifts the whole line. child.update(timer: "", meter: meter(nil), activity: "", pid: "") end spinners.auto_spin list.each_index.map { |i| results[i] } end |
.countdown(left) ⇒ String
The countdown that sits between the spinner and the meter: what is left of the agent's timeout, quiet grey until the last TIMER_WARNING seconds, red from there down.
261 262 263 264 265 266 |
# File 'lib/agentilda/ui.rb', line 261 def countdown(left) return "" if left.nil? text = fit(format("%d:%02d", left / 60, left % 60), TIMER_WIDTH) paint(text, (left <= TIMER_WARNING) ? :red : :bright_black) end |
.default_jobs ⇒ Integer
A sensible worker count: agents are mostly waiting on a model rather than burning CPU, so this is deliberately close to the core count. Two are left for the machine, and the cap keeps a very large tree from opening fifty subprocesses at once.
547 |
# File 'lib/agentilda/ui.rb', line 547 def default_jobs = (Etc.nprocessors - 2).clamp(1, 12) |
.display_width(text) ⇒ Integer
Returns how many terminal cells the text occupies.
564 |
# File 'lib/agentilda/ui.rb', line 564 def display_width(text) = Unicode::DisplayWidth.of(text.to_s) |
.elapsed(started) ⇒ String
Returns e.g. "42s".
527 |
# File 'lib/agentilda/ui.rb', line 527 def elapsed(started) = "#{(monotonic - started).round}s" |
.fit(text, width) ⇒ String
Pad or truncate to an exact number of terminal cells.
format's "%-20.20s" counts characters, and a character is not a cell.
"✅" is one character two cells wide; "🅱️" is two characters one cell
wide. Any column laid out with %s therefore drifts by one for every
emoji whose two counts disagree — which is every emoji, in one
direction or the other.
578 579 580 581 582 |
# File 'lib/agentilda/ui.rb', line 578 def fit(text, width) text = text.to_s text = text[0..-2] while display_width(text) > width text + (" " * (width - display_width(text))) end |
.line(message, bullet: "·") ⇒ void
This method returns an undefined value.
A single unadorned line, for per-item progress that does not deserve a box of its own.
standard:disable Style/StderrPuts -- see box: warn is a no-op under -W0.
650 |
# File 'lib/agentilda/ui.rb', line 650 def line(, bullet: "·") = $stderr.puts(" #{paint(bullet, :bright_black)} #{}") |
.log(message, **fields) ⇒ void
This method returns an undefined value.
Append one timestamped line to log_path. A no-op with nothing set. Safe to call from several threads at once.
220 221 222 223 224 225 226 227 228 |
# File 'lib/agentilda/ui.rb', line 220 def log(, **fields) path = log_path or return line = ProgressLog.render(, **fields) (@log_mutex ||= Mutex.new).synchronize do FileUtils.mkdir_p(File.dirname(path)) File.open(path, "a") { |f| f.puts(line) } end end |
.logging_activity(text) ⇒ Proc
The same news, with no spinner to put it on. A piped or CI run still wants it, in the log where the rest of that run's progress goes.
424 |
# File 'lib/agentilda/ui.rb', line 424 def logging_activity(text) = ->(phrase) { log("#{text}: #{phrase}") } |
.meter(update) ⇒ String
The token counter that sits between the spinner and the agent's name.
Up is everything sent, cache reads included, which is most of it. Down
is what the model generated. Sub-agent spend is folded into up, since
claude reports a sub-agent's total without splitting it.
245 246 247 248 |
# File 'lib/agentilda/ui.rb', line 245 def meter(update) paint(fit("↑#{abbreviate(update&.up)}", METER_WIDTH), :bright_blue) + paint(fit("↓#{abbreviate(update&.down)}", METER_WIDTH), :bright_magenta) end |
.monotonic ⇒ Float
Returns a monotonic clock reading, immune to wall-clock changes.
523 |
# File 'lib/agentilda/ui.rb', line 523 def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC) |
.once(item, label, fields = NO_FIELDS, failure: NO_FAILURE, timeout: NO_TIMEOUT) {|item| ... } ⇒ Object
One item, no concurrency to speak of: a serial round (--isolation shared), or the last plan left in a parallel one. A live spinner on a
terminal; a start line and a finish line with an elapsed time otherwise.
449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 |
# File 'lib/agentilda/ui.rb', line 449 def once(item, label, fields = NO_FIELDS, failure: NO_FAILURE, timeout: NO_TIMEOUT, &block) text = label.call(item) line = Line.new(fields: fields.call(item), mark: paint("done", :bright_black), timeout: timeout.call(item), spinner: (solo_spinner(text) if animate?)) line.start begin result = block.call(item, line) rescue => e reason = e..lines.first.to_s.strip line.failed(reason) report_line("#{text}: #{reason}", bullet: "✗") unless animate? raise end if (reason = failure.call(result)) line.failed(reason) report_line("#{text}: #{reason}", bullet: "✗") unless animate? else line.done report_line("#{text} (#{line.alive})", bullet: "✓") unless animate? end result end |
.paint(text, *styles) ⇒ String
Returns decorated when colour is on, bare otherwise.
560 |
# File 'lib/agentilda/ui.rb', line 560 def paint(text, *styles) = color? ? pastel.decorate(text.to_s, *styles) : text.to_s |
.pastel ⇒ Pastel
Returns colour engine, disabled when STDERR is not a terminal.
191 |
# File 'lib/agentilda/ui.rb', line 191 def pastel = @pastel ||= Pastel.new(enabled: color?) |
.popup(title, text) ⇒ void
This method returns an undefined value.
A framed panel centered on the screen, for the keyboard help. Unlike box it positions itself absolutely, so it overlays whatever the spinners are drawing rather than scrolling in below them — the next repaint draws over it, which is all the dismissal a help screen needs.
standard:disable Style/StderrPuts -- see box: warn is a no-op under -W0.
614 615 616 617 618 619 620 621 622 623 624 625 |
# File 'lib/agentilda/ui.rb', line 614 def popup(title, text) lines = text.to_s.lines box_width = [lines.map { |l| display_width(l.chomp) }.max.to_i + 6, TTY::Screen.width].min box_height = lines.size + 4 $stderr.print TTY::Box.frame( top: [(TTY::Screen.height - box_height) / 2, 0].max, left: [(TTY::Screen.width - box_width) / 2, 0].max, width: box_width, height: box_height, padding: 1, title: {top_left: " #{title} "}, enable_color: color?, style: color? ? {border: {fg: :cyan}} : {} ) { text.to_s } end |
.report_line(text, bullet: "·") ⇒ void
This method returns an undefined value.
A progress line, thread-safe and quiet-aware — the one thing every non-spinner path above needs and would otherwise have to reimplement.
535 536 537 538 539 |
# File 'lib/agentilda/ui.rb', line 535 def report_line(text, bullet: "·") return if quiet (@print_mutex ||= Mutex.new).synchronize { line(text, bullet:) } end |
.reset! ⇒ void
This method returns an undefined value.
Forget everything memoized here.
Pastel captures enabled: once, at construction. Anything that changes
the answer afterwards — a test stubbing tty?, a caller setting
NO_COLOR late — would otherwise be ignored for the rest of the process,
and the first answer would leak into every later call.
201 202 203 204 205 |
# File 'lib/agentilda/ui.rb', line 201 def reset! remove_instance_variable(:@pastel) if instance_variable_defined?(:@pastel) self.quiet = false self.log_path = nil end |
.said(phrase) ⇒ String
Returns the phrase as a spinner line carries it.
286 |
# File 'lib/agentilda/ui.rb', line 286 def said(phrase) = phrase.to_s.empty? ? "" : paint(": #{phrase}", :green, :bold) |
.solo_spinner(text) ⇒ TTY::Spinner
The spinner a lone agent gets. Registered nowhere, because there is no second line for it to line up with.
477 478 479 480 481 482 483 |
# File 'lib/agentilda/ui.rb', line 477 def solo_spinner(text) spinner = TTY::Spinner.new("[:spinner] :timer:meter#{text}:pid:activity", format: :dots, output: $stderr, success_mark: paint("✓", :green), error_mark: paint("✖", :red)) spinner.update(timer: "", meter: meter(nil), activity: "", pid: "") spinner.auto_spin spinner end |
.spinning(message) ⇒ Object
Indeterminate work — one call whose duration cannot be predicted, such as a network round trip. The spinner runs until the block returns.
303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 |
# File 'lib/agentilda/ui.rb', line 303 def spinning() return yield(logging_activity()) unless animate? spinner = TTY::Spinner.new("[:spinner] #{}:activity", format: :dots, output: $stderr, success_mark: paint("✓", :green), error_mark: paint("✖", :red)) spinner.update(activity: "") spinner.auto_spin begin result = yield(activity_for(spinner)) spinner.success(paint("done", :bright_black)) result rescue spinner.error(paint("failed", :red)) raise end end |
.stepping(items, message) {|item| ... } ⇒ Array
Determinate work — N items of roughly equal cost. Yields each item and advances the bar; returns the collection so it can be chained.
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 |
# File 'lib/agentilda/ui.rb', line 327 def stepping(items, , &) list = items.to_a return list.each(&) unless animate? && list.size >= PROGRESS_THRESHOLD = TTY::ProgressBar.new( "#{} [:bar] :current/:total :percent", total: list.size, output: $stderr, width: 24, complete: "█", incomplete: "░", head: "█" ) list.each do |item| yield item .advance end .finish list end |
.threaded(list, jobs, message, label: :to_s.to_proc, fields: NO_FIELDS, failure: NO_FAILURE) ⇒ Array
Parallelism with no spinner to draw: a pipe, a CI log, or a headless agent's own tool call. Still reports a start and a finish line per item, because "no terminal" is not the same question as "no one is reading this."
495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 |
# File 'lib/agentilda/ui.rb', line 495 def threaded(list, jobs, , label: :to_s.to_proc, fields: NO_FIELDS, failure: NO_FAILURE, &) report_line() results = Concurrent::Hash.new queue = Queue.new list.each_with_index { |item, index| queue << [item, index] } [jobs, list.size].min.times.map { Thread.new do while (pair = begin queue.pop(true) rescue ThreadError nil end) item, index = pair results[index] = begin once(item, label, fields, failure:, &) rescue => e e end end end }.each(&:join) list.each_index.map { |i| results[i] } end |
.tty? ⇒ Boolean
Returns whether STDERR is an interactive terminal.
289 |
# File 'lib/agentilda/ui.rb', line 289 def tty? = $stderr.tty? |
Instance Method Details
#error(message) ⇒ void
This method returns an undefined value.
665 |
# File 'lib/agentilda/ui.rb', line 665 def error() = UI.box(:error, ) |
#info(message) ⇒ void
This method returns an undefined value.
657 |
# File 'lib/agentilda/ui.rb', line 657 def info() = UI.box(:info, ) |
#paint(text, *styles) ⇒ String
679 |
# File 'lib/agentilda/ui.rb', line 679 def paint(text, *styles) = UI.paint(text, *styles) |
#say(message, bullet: "·") ⇒ void
This method returns an undefined value.
674 |
# File 'lib/agentilda/ui.rb', line 674 def say(, bullet: "·") = UI.line(, bullet:) |