agentic examples

The TTY Status Board

The TTY Status Board: terminal output is a UI, and UIs are built from COMPONENTS - a tree for structure, gauges for progress, badges for state - not from puts sprinkled where the mood struck. This renders a plan's live state as composed components, three frames of it, driven entirely by lifecycle hooks. No curses, no deps: the component discipline is the point, not the escape codes.

Patterns Round 13 Piotr Murach exit 0

source on github

bundle exec ruby examples/tty_status.rb

a real captured run

THE TTY STATUS BOARD (three frames from one run)

  +--------------------------------+
  | after fetch feeds              |
  +--------------------------------+
  | [x] fetch feeds                |
  | |-- [ ] parse entries          |
  |     |-- [ ] rank stories       |
  |         `-- [ ] publish digest |
  |                                |
  | |======                  | 1/4 |
  +--------------------------------+

  +--------------------------------+
  | after parse entries            |
  +--------------------------------+
  | [x] fetch feeds                |
  | |-- [x] parse entries          |
  |     |-- [ ] rank stories       |
  |         `-- [ ] publish digest |
  |                                |
  | |============            | 2/4 |
  +--------------------------------+

  +--------------------------------+
  | after publish digest           |
  +--------------------------------+
  | [x] fetch feeds                |
  | |-- [x] parse entries          |
  |     |-- [x] rank stories       |
  |         `-- [x] publish digest |
  |                                |
  | |========================| 4/4 |
  +--------------------------------+

  each piece is a component with one job: badge (state to glyph),
  gauge (counts to bar), tree (depth to indent), frame (lines to
  box) - and the board only composes them. that separation is the
  whole tty-* toolbox philosophy: when the spinner needs to become
  a progress bar, you swap ONE component and no rendering code
  learns about it. the hooks hand over exactly what a UI needs
  (state transitions with names), the graph hands over structure
  (depth, order), and the terminal gets what every user deserves:
  an interface that was designed, not accreted.

source

# frozen_string_literal: true

# The TTY Status Board: terminal output is a UI, and UIs are built
# from COMPONENTS - a tree for structure, gauges for progress, badges
# for state - not from puts sprinkled where the mood struck. This
# renders a plan's live state as composed components, three frames of
# it, driven entirely by lifecycle hooks. No curses, no deps: the
# component discipline is the point, not the escape codes.
#
#   bundle exec ruby examples/tty_status.rb
#
# Runs offline; frames are captured at plan milestones.

require class="s">"bundler/setup"
require class="s">"agentic"

Agentic.logger.level = class="y">:fatal

# --- tiny components, each one testable alone -----------------------------------
module UI
  BADGES = {pending: class="s">"[ ]", running: class="s">"[~]", done: class="s">"[x]", failed: class="s">"[!]"}.freeze

  def self.badge(state) = BADGES.fetch(state)

  def self.gauge(done, total, width: 24)
    filled = (total.zero? ? 0 : done * width / total)
    class="s">"|#{"=class="s">" * filled}#{" class="s">" * (width - filled)}| #{done}/#{total}"
  end

  def self.tree(rows)
    rows.map.with_index { |(depth, text), i|
      glyph = (i == rows.size - 1) ? class="s">"`-- " : class="s">"|-- "
      (depth == 1) ? text : class="s">"#{"    class="s">" * (depth - 2)}#{glyph}#{text}"
    }
  end

  def self.frame(title, lines)
    width = ([title.size] + lines.map(&class="y">:size)).max + 2
    [class="s">"+#{"-class="s">" * width}+", class="s">"| #{title.ljust(width - 1)}|", class="s">"+#{"-class="s">" * width}+"] +
      lines.map { |l| class="s">"| #{l.ljust(width - 1)}|" } + [class="s">"+#{"-class="s">" * width}+"]
  end
end

# --- the board: hook events in, frames out ---------------------------------------
class StatusBoard
  def initialize(graph)
    @graph = graph
    @states = graph[class="y">:tasks].keys.to_h { |id| [id, class="y">:pending] }
    @frames = []
  end

  attr_reader class="y">:frames

  def hooks
    {
      before_task_execution: ->(task_id:, task:) { @states[task_id] = class="y">:running },
      after_task_success: ->(task_id:, task:, result:, duration:) {
        @states[task_id] = class="y">:done
        snapshot(class="s">"after #{task.description}")
      },
      after_task_failure: ->(task_id:, task:, failure:, duration:) {
        @states[task_id] = class="y">:failed
        snapshot(class="s">"after #{task.description} FAILED")
      }
    }
  end

  def snapshot(caption)
    rows = @graph[class="y">:order].map { |id|
      [@graph[class="y">:stats][class="y">:depth][id], class="s">"#{UI.badge(@states[id])} #{@graph[class="y">:tasks][id].description}"]
    }
    done = @states.values.count(class="y">:done)
    @frames << UI.frame(caption, UI.tree(rows) + [class="s">"", UI.gauge(done, @states.size)])
  end
end

orchestrator = Agentic:class="y">:PlanOrchestrator.new(concurrency_limit: 2)
fetch = Agentic:class="y">:Task.new(description: class="s">"fetch feeds", agent_spec: {class="s">"name" => class="s">"f", class="s">"instructions" => class="s">"w"})
parse = Agentic:class="y">:Task.new(description: class="s">"parse entries", agent_spec: {class="s">"name" => class="s">"p", class="s">"instructions" => class="s">"w"})
rank = Agentic:class="y">:Task.new(description: class="s">"rank stories", agent_spec: {class="s">"name" => class="s">"r", class="s">"instructions" => class="s">"w"})
publish = Agentic:class="y">:Task.new(description: class="s">"publish digest", agent_spec: {class="s">"name" => class="s">"d", class="s">"instructions" => class="s">"w"})
orchestrator.add_task(fetch, agent: ->(_t) { sleep(0.01) })
orchestrator.add_task(parse, [fetch], agent: ->(_t) { sleep(0.01) })
orchestrator.add_task(rank, [parse], agent: ->(_t) { sleep(0.01) })
orchestrator.add_task(publish, [rank], agent: ->(_t) { sleep(0.01) })

board = StatusBoard.new(orchestrator.graph)
orchestrator2 = Agentic:class="y">:PlanOrchestrator.new(concurrency_limit: 2, lifecycle_hooks: board.hooks)
[fetch, parse, rank, publish].each_with_index do |task, i|
  deps = i.zero? ? [] : [[fetch, parse, rank][i - 1]]
  orchestrator2.add_task(task, deps, agent: ->(_t) { sleep(0.005) })
end
orchestrator2.execute_plan

puts class="s">"THE TTY STATUS BOARD (three frames from one run)"
puts
[0, 1, 3].each do |index|
  board.frames[index].each { |line| puts class="s">"  #{line}" }
  puts
end
puts class="s">"  each piece is a component with one job: badge (state to glyph),"
puts class="s">"  gauge (counts to bar), tree (depth to indent), frame (lines to"
puts class="s">"  box) - and the board only composes them. that separation is the"
puts class="s">"  whole tty-* toolbox philosophy: when the spinner needs to become"
puts class="s">"  a progress bar, you swap ONE component and no rendering code"
puts class="s">"  learns about it. the hooks hand over exactly what a UI needs"
puts class="s">"  (state transitions with names), the graph hands over structure"
puts class="s">"  (depth, order), and the terminal gets what every user deserves:"
puts class="s">"  an interface that was designed, not accreted."