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.

Examples:

class Thing
  include Agentilda::UI
  def run = success("Linked 4 skills")
end

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:SS included — the run default of 900s reads 15:00, and padding to a fixed width keeps the columns to its right from stepping sideways once 9:59 loses a digit.

6

Class Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Class Attribute Details

.log_pathString?

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.

Returns:

  • (String, nil)


213
214
215
# File 'lib/agentilda/ui.rb', line 213

def log_path
  @log_path
end

.quietBoolean

Set by the CLI's --quiet. Silences spinners and bars along with everything else, so a quiet run really is quiet.

Returns:

  • (Boolean)


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.

Parameters:

  • count (Integer)

Returns:

  • (String)

    e.g. "512", "4.9k", "121k", "1.6M"



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.

Parameters:

  • spinner (TTY::Spinner)

Returns:

  • (Proc)

    phrase -> void



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.

Returns:

  • (Boolean)


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.

Parameters:

  • kind (Symbol)

    :info, :warn, :error or :success

  • message (String)


598
599
600
601
# File 'lib/agentilda/ui.rb', line 598

def box(kind, message)
  text = message.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.

Parameters:

  • text (String)

Returns:

  • (Integer)

    rows the box needs: its content, two borders, one pad



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

Returns:

  • (Boolean)


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."

Parameters:

  • items (Array)
  • message (String)

    the header line

  • jobs (Integer)

    how many run at once

  • label (Proc) (defaults to: :to_s.to_proc)

    item -> the text on its line

  • failure (Proc) (defaults to: NO_FAILURE)

    the block's return value -> a reason when that value reports a failure, nil when it reports success. The block returning normally is not the same fact as the work having worked.

  • header (Hash) (defaults to: {})

    log columns for the header line itself, so the line announcing a round carries the same round number as the agent lines under it rather than a blank cell

Yield Parameters:

  • item (Object)

Returns:

  • (Array)

    one result per item, in input order



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, message, 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(message, **header)

  if jobs <= 1 || list.size <= 1
    report_line(message) unless animate?
    return list.map { |item| once(item, label, fields, failure:, timeout:, &block) }
  end

  return threaded(list, jobs, message, label:, fields:, failure:, &block) unless animate?

  results = Concurrent::Hash.new
  spinners = TTY::Spinner::Multi.new(
    ":spinner #{paint(message, :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.message.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.

Parameters:

  • left (Integer, nil)

    seconds remaining; nil draws nothing

Returns:

  • (String)


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_jobsInteger

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.

Returns:

  • (Integer)


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.

Parameters:

  • text (String)

Returns:

  • (Integer)

    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".

Parameters:

  • started (Float)

    a #monotonic reading taken before the work began

Returns:

  • (String)

    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.

Parameters:

  • text (String)

    unpainted; escape codes count as characters and would be padded like any other

  • width (Integer)

    terminal cells

Returns:

  • (String)


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.

Parameters:

  • message (String)
  • bullet (String) (defaults to: "·")


650
# File 'lib/agentilda/ui.rb', line 650

def line(message, bullet: "·") = $stderr.puts("  #{paint(bullet, :bright_black)} #{message}")

.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.

Parameters:

  • message (String)


220
221
222
223
224
225
226
227
228
# File 'lib/agentilda/ui.rb', line 220

def log(message, **fields)
  path = log_path or return

  line = ProgressLog.render(message, **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.

Parameters:

  • text (String)

    the item's label

Returns:

  • (Proc)

    phrase -> void



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.

Parameters:

Returns:

  • (String)


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

.monotonicFloat

Returns a monotonic clock reading, immune to wall-clock changes.

Returns:

  • (Float)

    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.

Parameters:

  • item (Object)
  • label (Proc)

Yield Parameters:

  • item (Object)

Returns:

  • (Object)


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.message.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.

Parameters:

  • text (String)
  • styles (Array<Symbol>)

    pastel style names

Returns:

  • (String)

    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

.pastelPastel

Returns colour engine, disabled when STDERR is not a terminal.

Returns:

  • (Pastel)

    colour engine, disabled when STDERR is not a terminal



191
# File 'lib/agentilda/ui.rb', line 191

def pastel = @pastel ||= Pastel.new(enabled: color?)

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.

Parameters:

  • title (String)
  • text (String)


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.

Parameters:

  • text (String)
  • bullet (String) (defaults to: "·")


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.

Parameters:

  • phrase (String, nil)

Returns:

  • (String)

    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.

Parameters:

  • text (String)

Returns:

  • (TTY::Spinner)


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.

Parameters:

  • message (String)

    what is being waited on

Yield Returns:

  • (Object)

    whatever the work produces

Returns:

  • (Object)

    the block's value, untouched



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(message)
  return yield(logging_activity(message)) unless animate?

  spinner = TTY::Spinner.new("[:spinner] #{message}: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.

Parameters:

  • items (Array)
  • message (String)

Yield Parameters:

  • item (Object)

Returns:

  • (Array)

    items



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, message, &)
  list = items.to_a
  return list.each(&) unless animate? && list.size >= PROGRESS_THRESHOLD

  bar = TTY::ProgressBar.new(
    "#{message} [:bar] :current/:total :percent",
    total: list.size, output: $stderr, width: 24,
    complete: "", incomplete: "", head: ""
  )
  list.each do |item|
    yield item
    bar.advance
  end
  bar.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."

Parameters:

  • list (Array)
  • jobs (Integer)
  • message (String)
  • label (Proc) (defaults to: :to_s.to_proc)

Returns:

  • (Array)


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, message, label: :to_s.to_proc, fields: NO_FIELDS,
  failure: NO_FAILURE, &)
  report_line(message)
  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.

Returns:

  • (Boolean)

    whether STDERR is an interactive terminal



289
# File 'lib/agentilda/ui.rb', line 289

def tty? = $stderr.tty?

.widthInteger

Returns a box width that fits the terminal but stays readable.

Returns:

  • (Integer)

    a box width that fits the terminal but stays readable



555
# File 'lib/agentilda/ui.rb', line 555

def width = (TTY::Screen.width - 4).clamp(MIN_WIDTH, MAX_WIDTH)

Instance Method Details

#error(message) ⇒ void

This method returns an undefined value.

Parameters:

  • message (String)


665
# File 'lib/agentilda/ui.rb', line 665

def error(message) = UI.box(:error, message)

#info(message) ⇒ void

This method returns an undefined value.

Parameters:

  • message (String)


657
# File 'lib/agentilda/ui.rb', line 657

def info(message) = UI.box(:info, message)

#paint(text, *styles) ⇒ String

Parameters:

  • text (String)
  • styles (Array<Symbol>)

Returns:

  • (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.

Parameters:

  • message (String)
  • bullet (String) (defaults to: "·")


674
# File 'lib/agentilda/ui.rb', line 674

def say(message, bullet: "·") = UI.line(message, bullet:)

#success(message) ⇒ void

This method returns an undefined value.

Parameters:

  • message (String)


669
# File 'lib/agentilda/ui.rb', line 669

def success(message) = UI.box(:success, message)

#warn(message) ⇒ void

This method returns an undefined value.

Parameters:

  • message (String)


661
# File 'lib/agentilda/ui.rb', line 661

def warn(message) = UI.box(:warn, message)