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