agentilda 2.0.1 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Presentation
5
+ # `/spec-status` — every plan, its state in icon and words, and its pull
6
+ # requests as clickable links.
7
+ #
8
+ # {#render} returns a string and prints nothing. Whoever calls it decides
9
+ # where it goes, which keeps the table pipeable.
10
+ class Reporter
11
+ # Columns, so the header and the rows cannot drift apart.
12
+ HEADINGS = ["Plan", "⬇", "Status", "Feature", "Pull Requests"].freeze
13
+
14
+ # Cells given to the plan number: "018.01" with room to grow.
15
+ ORDINAL_WIDTH = 7
16
+
17
+ # Cells given to a status icon. Emoji vary in both character count and
18
+ # display width, so the cell is padded to this by {UI.fit} rather than
19
+ # trusted to be any particular size — see the note there.
20
+ EMOJI_WIDTH = 2
21
+
22
+ # Cells given to a pull request's state, so the URLs line up beneath each
23
+ # other whichever states the tree happens to contain.
24
+ STATE_WIDTH = Plans::PullRequests::STATES.map { |_, label| UI.display_width(label) }.max
25
+
26
+ # @param tree [Agentilda::Plans::Tree]
27
+ def initialize(tree:)
28
+ @tree = tree
29
+ end
30
+
31
+ # @return [Agentilda::Plans::Tree]
32
+ attr_reader :tree
33
+
34
+ # @return [String] the whole table, newline-terminated
35
+ def render
36
+ return "No plans found in #{tree.dir}\n" if tree.subjects.empty?
37
+
38
+ [header, *tree.subjects.flat_map { |s| rows_for(s) }, "", footer].join("\n") + "\n"
39
+ end
40
+
41
+ # @return [Hash{Symbol => Integer}] how many plans sit in each state
42
+ def totals
43
+ tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status.key] += 1 }
44
+ end
45
+
46
+ # Plans whose folder name their contents do not justify.
47
+ #
48
+ # @return [Array<Agentilda::Plans::Subject>]
49
+ def inconsistent = tree.subjects.reject(&:consistent?)
50
+
51
+ private
52
+
53
+ # @return [Integer] widest status label, so the column lines up
54
+ def label_width = @label_width ||= tree.subjects.map { |s| UI.display_width(s.status.label) }.max.to_i + 4
55
+
56
+ # @return [Integer] widest feature name
57
+ def title_width = @title_width ||= tree.subjects.map { |s| UI.display_width(s.feature.title) }.max.to_i + 2
58
+
59
+ # The one place the table's shape is written down. The heading, the plan
60
+ # rows, the continuation lines and the violations all come through here, so
61
+ # a column cannot widen in one of them and stay put in the others.
62
+ #
63
+ # Cells arrive already fitted and already painted, in that order, because
64
+ # the order matters: escape codes count toward a string's length, so
65
+ # padding a coloured string pads it to a width of which several characters
66
+ # are invisible, and the column collapses by exactly that much.
67
+ #
68
+ # @return [String]
69
+ def row(flag, ordinal, emoji, label, title, tail)
70
+ format("%s %s %s %s %s %s", flag, ordinal, emoji, label, title, tail)
71
+ end
72
+
73
+ # Cell at which the pull requests start, so a plan's second and third pull
74
+ # requests sit under its first. Measured by running blanks through {#row}
75
+ # rather than counted by hand, so the indent cannot drift when the layout
76
+ # changes.
77
+ #
78
+ # @return [Integer]
79
+ def pr_column
80
+ @pr_column ||= row(" ",
81
+ " " * ORDINAL_WIDTH,
82
+ " " * EMOJI_WIDTH,
83
+ " " * label_width,
84
+ " " * title_width,
85
+ "").length
86
+ end
87
+
88
+ # @return [Integer] cell at which the feature name starts
89
+ def title_column = pr_column - title_width - 1
90
+
91
+ # @return [String]
92
+ def header
93
+ text = row(" ",
94
+ UI.fit(HEADINGS[0], ORDINAL_WIDTH),
95
+ UI.fit(HEADINGS[1], EMOJI_WIDTH),
96
+ UI.fit(HEADINGS[2], label_width),
97
+ UI.fit(HEADINGS[3], title_width),
98
+ HEADINGS[4])
99
+
100
+ rule = " " + ("─" * (UI.display_width(text) - 2))
101
+
102
+ [UI.paint(text, :bold), UI.paint(rule, :bright_yellow)].join("\n")
103
+ end
104
+
105
+ # One line per plan, plus a continuation line for each extra pull request
106
+ # so that every URL is visible and clickable rather than truncated.
107
+ #
108
+ # @param subject [Agentilda::Plans::Subject]
109
+ # @return [Array<String>]
110
+ def rows_for(subject)
111
+ feature = subject.feature
112
+ prs = subject.pull_requests
113
+
114
+ first = row(
115
+ subject.consistent? ? " " : UI.paint("!", :red),
116
+ UI.paint(UI.fit(feature.ordinal.to_s, ORDINAL_WIDTH), :bold),
117
+ UI.fit(feature.status.emoji, EMOJI_WIDTH),
118
+ UI.paint(UI.fit(feature.status.label, label_width), :yellow),
119
+ UI.paint(UI.fit(feature.title, title_width), :blue, :bold),
120
+ prs.empty? ? UI.paint("[none yet]", :bright_red) : link(prs.first)
121
+ )
122
+
123
+ rest = prs.drop(1).map { |pr| (" " * pr_column) + link(pr) }
124
+
125
+ [first, *rest, *violation_line(subject)]
126
+ end
127
+
128
+ # The URL is left bare rather than wrapped in an OSC 8 hyperlink: the table
129
+ # is as likely to be piped into a file as read in a terminal, and every
130
+ # terminal worth the name linkifies a bare URL anyway.
131
+ #
132
+ # @param pr [Agentilda::Plans::PullRequest]
133
+ # @return [String]
134
+ def link(pr)
135
+ colour = state_colour(pr)
136
+
137
+ format("#%4s %s %s",
138
+ pr.number,
139
+ UI.paint(UI.fit(pr.state, STATE_WIDTH), colour),
140
+ UI.paint(pr.url.to_s, colour))
141
+ end
142
+
143
+ # @param pr [Agentilda::Plans::PullRequest]
144
+ # @return [Symbol] a pastel style name
145
+ def state_colour(pr)
146
+ if pr.merged?
147
+ :magenta
148
+ elsif pr.open?
149
+ :yellow
150
+ else
151
+ :blue
152
+ end
153
+ end
154
+
155
+ # @param subject [Agentilda::Plans::Subject]
156
+ # @return [Array<String>]
157
+ def violation_line(subject)
158
+ return [] if subject.consistent?
159
+
160
+ [(" " * title_column) + UI.paint("↳ #{subject.violation}", :red)]
161
+ end
162
+
163
+ # @return [String]
164
+ def footer
165
+ counts = totals.map { |key, n| "#{Plans::STATUS_BY_KEY.fetch(key).emoji} #{n}" }.join(" ")
166
+ lines = [" #{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} #{counts}"]
167
+
168
+ unless inconsistent.empty?
169
+ lines << " #{UI.paint("#{inconsistent.size} with a status their contents do not justify", :red)}"
170
+ end
171
+
172
+ tree.duplicates.each do |ordinal, dirnames|
173
+ lines << " #{UI.paint("#{ordinal} is claimed by #{dirnames.size} folders — a number is an identity:", :red)}"
174
+ dirnames.each { |d| lines << " #{UI.paint(d, :bright_black)}" }
175
+ end
176
+
177
+ lines.join("\n")
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ratatui_ruby"
4
+
5
+ module Agentilda
6
+ module Presentation
7
+ module Screen
8
+ class Ratatui
9
+ module Bar
10
+ # Cells the fill portion occupies.
11
+ WIDTH = 10
12
+
13
+ # Seconds a bar visually maxes out at. Cosmetic only — the color
14
+ # thresholds below are independent, absolute-minute rules.
15
+ CAP = 1800
16
+
17
+ # @param tui [RatatuiRuby::TUI]
18
+ # @param seconds [Integer, nil] nil draws an empty, dim bar
19
+ # @param color [Symbol] the fill's foreground color
20
+ # @return [RatatuiRuby::Text::Line]
21
+ def self.cell(tui, seconds, color)
22
+ return empty(tui) if seconds.nil?
23
+
24
+ filled = ((seconds.to_f / CAP).clamp(0.0, 1.0) * WIDTH).round
25
+ bar = ("▓" * filled) + ("░" * (WIDTH - filled))
26
+ clock = format(" %2d:%02d", seconds / 60, seconds % 60)
27
+ tui.text_line(spans: [
28
+ tui.text_span(content: bar, style: tui.style(fg: color)),
29
+ tui.text_span(content: clock, style: tui.style(fg: :bright_black))
30
+ ])
31
+ end
32
+
33
+ # @param tui [RatatuiRuby::TUI]
34
+ # @return [RatatuiRuby::Text::Line]
35
+ def self.empty(tui)
36
+ tui.text_line(spans: [
37
+ tui.text_span(content: " " * WIDTH, style: tui.style(fg: :bright_black)),
38
+ tui.text_span(content: " --:--", style: tui.style(fg: :bright_black))
39
+ ])
40
+ end
41
+
42
+ # @param elapsed [Integer]
43
+ # @return [Symbol]
44
+ def self.elapsed_color(elapsed) = elapsed >= 900 ? :yellow : :green
45
+
46
+ # @param remaining [Integer, nil]
47
+ # @return [Symbol]
48
+ def self.remaining_color(remaining)
49
+ return :bright_black if remaining.nil?
50
+ return :red if remaining < 300
51
+ return :yellow if remaining < 900
52
+
53
+ :green
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ratatui_ruby"
4
+
5
+ module Agentilda
6
+ module Presentation
7
+ module Screen
8
+ class Ratatui
9
+ # RatatuiRuby::Event -> the exact string Keyboard#handle already
10
+ # expects, so every key binding (select, kill, extend, wrap-up, quit,
11
+ # the dialog, ctrl-c) is reused verbatim rather than reimplemented.
12
+ module KeyTranslator
13
+ # @param event [RatatuiRuby::Event]
14
+ # @return [String, nil] nil for anything that is not a key press
15
+ def self.call(event)
16
+ return nil unless event.key?
17
+ return "\u0003" if event.interrupt?
18
+
19
+ case event.code
20
+ when "up" then "\e[A"
21
+ when "down" then "\e[B"
22
+ when "enter" then "\r"
23
+ when "esc" then "\e"
24
+ else event.code
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ratatui_ruby"
4
+
5
+ module Agentilda
6
+ module Presentation
7
+ module Screen
8
+ # A ratatui_ruby-backed alternative to {Screen}, selected with `--tui
9
+ # ratatui`. Unlike {Screen}, whose #draw renders synchronously from the
10
+ # dispatcher's own tick, ratatui_ruby owns a blocking render+input loop
11
+ # of its own — see the lifecycle methods added in the next task. This
12
+ # class carries the parts of that loop with no terminal dependency: the
13
+ # widget tree built from a {Board}.
14
+ class Ratatui
15
+ # Cells each column takes. The last one is stretched to the edge by
16
+ # ratatui's legacy flex. The elapsed/time-left columns (indices 8 and 9) are 17,
17
+ # not 13: {Bar.cell} emits a 10-cell bar plus a clock suffix up to 7
18
+ # characters wide (e.g. " 12:41"), and a narrower column clips the
19
+ # clock digits once ratatui actually lays the table out (see
20
+ # spec/agentilda/screen/ratatui_spec.rb's real-terminal regression
21
+ # test, which is the only one of this file's specs that can see
22
+ # that — the rest inspect the pre-layout `Text::Line` object).
23
+ COLUMNS = [9, 7, 16, 18, 8, 8, 5, 13, 17, 17].freeze
24
+
25
+ HEADER = %w[time plan feature agent round model subs tokens elapsed left].freeze
26
+
27
+ # Blank lines between two agents.
28
+ ROW_GAP = 1
29
+
30
+ # The highlight symbol's column. Reserved even with nothing selected,
31
+ # so the activity line's x never shifts when a selection appears.
32
+ HIGHLIGHT_SYMBOL = "> "
33
+
34
+ # The activity line starts under the feature column: past the
35
+ # highlight symbol, time and plan, each column plus its 1-cell gap.
36
+ ACTIVITY_INDENT = HIGHLIGHT_SYMBOL.length + COLUMNS[0] + 1 + COLUMNS[1] + 1
37
+
38
+ # Cells a status line stops short of the terminal's right edge.
39
+ ACTIVITY_MARGIN = 5
40
+
41
+ # Status lines drawn under an agent's row when nobody says otherwise.
42
+ SCROLL_HEIGHT = 3
43
+
44
+ # Seconds between samples for the running-agent-count sparkline.
45
+ SAMPLE_INTERVAL = 10
46
+
47
+ # Points the sparkline keeps before the oldest scrolls off the left.
48
+ HISTORY = 60
49
+
50
+ # @return [Array<Integer>] running-agent-count samples, oldest first
51
+ attr_reader :history
52
+
53
+ # @return [Integer] status lines drawn under each agent's row
54
+ attr_reader :scroll_height
55
+
56
+ # @param runner [#call] `RatatuiRuby.method(:run)` by default; a fake
57
+ # in tests, so this class never needs a real terminal to exercise
58
+ # its own orchestration
59
+ # @param scroll_height [Integer] how many of an agent's latest statuses
60
+ # show under its row, newest at the top
61
+ def initialize(runner: RatatuiRuby.method(:run), scroll_height: SCROLL_HEIGHT)
62
+ @runner = runner
63
+ @scroll_height = Integer(scroll_height).clamp(1, Engine::Board::Row::HISTORY)
64
+ @board = nil
65
+ @keyboard = nil
66
+ @history = []
67
+ @last_sample = nil
68
+ # Read from the dispatcher's thread in #tick's `until @closing` via
69
+ # #run_loop, written from the caller's thread in #close. Safe only
70
+ # under MRI/CRuby's GVL, which serializes the read and the write;
71
+ # a GVL-free Ruby would need a real memory barrier here.
72
+ @closing = false
73
+ @error = nil
74
+ @mutex = Mutex.new
75
+ end
76
+
77
+ # @param keyboard [Agentilda::Presentation::Keyboard] built with `.new`, never
78
+ # `.start`'d — this is what feeds it keys, from the thread
79
+ # ratatui_ruby's own loop runs on.
80
+ # @return [void]
81
+ def attach_keyboard(keyboard) = @keyboard = keyboard
82
+
83
+ # @return [void]
84
+ def open
85
+ @closing = false
86
+ @error = nil
87
+ @thread = Thread.new do
88
+ Thread.current.report_on_exception = false
89
+ begin
90
+ @runner.call { |tui| run_loop(tui) }
91
+ rescue StandardError => e
92
+ # A background render thread's job is never to crash the run:
93
+ # #close's `@thread&.join` would otherwise re-raise this inside
94
+ # `run.rb`'s `ensure`, after every agent has already run,
95
+ # discarding the whole run report. Stash it; #close surfaces it
96
+ # with a one-line warning instead.
97
+ @error = e
98
+ end
99
+ end
100
+ end
101
+
102
+ # Called by {Console#paint} from the dispatcher's own thread, exactly
103
+ # as {Screen#draw} is. Stores the board; the ratatui thread picks it
104
+ # up on its own next tick, a fraction of a frame later.
105
+ #
106
+ # @param board [Agentilda::Engine::Board]
107
+ # @return [void]
108
+ def draw(board) = @mutex.synchronize { @board = board }
109
+
110
+ # @return [void]
111
+ def close
112
+ @closing = true
113
+ @thread&.join
114
+ @thread = nil
115
+ UI.line("ratatui backend failed: #{@error.message} (the run itself continued; nothing was drawn)") if @error
116
+ end
117
+
118
+ # @param tui [RatatuiRuby::TUI]
119
+ # @return [void]
120
+ def run_loop(tui)
121
+ # `TableState.new`'s documented `selected = nil` default isn't
122
+ # honored by the native binding — it raises ArgumentError given
123
+ # zero arguments (see spec/agentilda/screen/ratatui_spec.rb's own
124
+ # `table_state` let) — so the nil has to be explicit.
125
+ table_state = tui.table_state(nil)
126
+ tick(tui, table_state) until @closing
127
+ end
128
+
129
+ # One frame: draw the latest board, sample the sparkline, forward the
130
+ # next key. Public so the lifecycle spec can drive it without needing
131
+ # to manage the `until @closing` loop.
132
+ #
133
+ # @param tui [RatatuiRuby::TUI]
134
+ # @param table_state [RatatuiRuby::TableState]
135
+ # @return [void]
136
+ def tick(tui, table_state)
137
+ board = @mutex.synchronize { @board }
138
+ unless board
139
+ # `tui.poll_event` below is what paces this loop (ratatui's
140
+ # default poll timeout is ~16ms); returning ahead of it, as here,
141
+ # would otherwise spin a CPU core flat out until the dispatcher's
142
+ # first paint.
143
+ sleep(0.05)
144
+ return
145
+ end
146
+
147
+ sample(board)
148
+ tui.draw { |frame| render(tui, frame, frame.area, board, table_state) }
149
+ forward(tui.poll_event)
150
+ end
151
+
152
+ # @param tui [RatatuiRuby::TUI]
153
+ # @param frame [RatatuiRuby::Frame]
154
+ # @param area [RatatuiRuby::Layout::Rect]
155
+ # @param board [Agentilda::Engine::Board]
156
+ # @param table_state [RatatuiRuby::TableState]
157
+ # @return [void]
158
+ def render(tui, frame, area, board, table_state)
159
+ sync_selection(board, table_state)
160
+ _, top, _, table_area, bottom = tui.layout_split(area,
161
+ direction: :vertical,
162
+ constraints: [tui.constraint_length(1), tui.constraint_length(1), tui.constraint_length(1),
163
+ tui.constraint_fill(1), tui.constraint_length(1)])
164
+ _bottom_text, bottom_spark = tui.layout_split(bottom,
165
+ direction: :horizontal,
166
+ constraints: [tui.constraint_fill(3), tui.constraint_fill(1)])
167
+
168
+ frame.render_widget(top_bar(tui, board), top)
169
+ frame.render_stateful_widget(table(tui, board), table_area, table_state)
170
+ render_activities(tui, frame, table_area, board, table_state)
171
+ # The bar is painted across the whole strip first, sparkline area
172
+ # included, so the cyan reaches both edges; the sparkline is then
173
+ # drawn over its right-hand share of it, in the same colors.
174
+ frame.render_widget(bottom_bar(tui, board), bottom)
175
+ frame.render_widget(tui.sparkline(data: @history, style: tui.style(fg: :black, bg: :cyan)), bottom_spark)
176
+ render_overlay(tui, frame, area, board)
177
+ end
178
+
179
+ # @param board [Agentilda::Engine::Board]
180
+ # @return [void]
181
+ def sample(board)
182
+ now = UI.monotonic
183
+ return if @last_sample && now - @last_sample < SAMPLE_INTERVAL
184
+
185
+ @last_sample = now
186
+ @history << board.running
187
+ @history.shift while @history.size > HISTORY
188
+ end
189
+
190
+ # @param event [RatatuiRuby::Event]
191
+ # @return [void]
192
+ def forward(event)
193
+ key = KeyTranslator.call(event) or return
194
+ @keyboard&.handle(key)
195
+ end
196
+
197
+ private
198
+
199
+ # @param board [Agentilda::Engine::Board]
200
+ # @param table_state [RatatuiRuby::TableState]
201
+ # @return [void]
202
+ def sync_selection(board, table_state)
203
+ index = board.selected && board.rows.index { |r| r.key == board.selected }
204
+ table_state.select(index)
205
+ end
206
+
207
+ # @return [RatatuiRuby::Widgets::Paragraph]
208
+ def top_bar(tui, board)
209
+ tui.paragraph(
210
+ text: "#{board.status} · plans: #{board.plans.join(", ")} " \
211
+ "· tokens ↑#{UI.abbreviate(board.up)} ↓#{UI.abbreviate(board.down)}",
212
+ style: tui.style(fg: :black, bg: :cyan)
213
+ )
214
+ end
215
+
216
+ # @return [RatatuiRuby::Widgets::Paragraph]
217
+ def bottom_bar(tui, board)
218
+ tui.paragraph(
219
+ text: "working in #{board.root} · agents running: #{board.running} " \
220
+ "· live ↑#{UI.abbreviate(board.live_up)} ↓#{UI.abbreviate(board.live_down)}",
221
+ style: tui.style(fg: :black, bg: :cyan)
222
+ )
223
+ end
224
+
225
+ # @return [RatatuiRuby::Widgets::Table]
226
+ def table(tui, board)
227
+ tui.table(header: HEADER,
228
+ rows: board.rows.map { |row| table_row(tui, row) },
229
+ widths: COLUMNS.map { |w| tui.constraint_length(w) },
230
+ highlight_symbol: HIGHLIGHT_SYMBOL,
231
+ highlight_spacing: :always,
232
+ row_highlight_style: tui.style(modifiers: [:reversed]))
233
+ end
234
+
235
+ # Lines one agent takes: its table row, then its statuses.
236
+ #
237
+ # @return [Integer]
238
+ def row_height = 1 + scroll_height
239
+
240
+ # Paints each visible agent's latest statuses over the empty lines
241
+ # under its table row, newest first: the newest bold, the rest plain,
242
+ # all yellow. A table cell cannot span columns, so a status would
243
+ # otherwise be clipped to one column's width; drawn on top, it gets
244
+ # everything from the feature column to {ACTIVITY_MARGIN} cells before
245
+ # the right edge, and a longer status ends in an ellipsis.
246
+ #
247
+ # @return [void]
248
+ def render_activities(tui, frame, table_area, board, table_state)
249
+ width = table_area.width - ACTIVITY_INDENT - ACTIVITY_MARGIN
250
+ return if width <= 0
251
+
252
+ bottom = table_area.y + table_area.height
253
+ board.rows.drop(table_state.offset.to_i).each_with_index do |row, index|
254
+ # +1 for the header line, +1 more to land under the row's own line.
255
+ top = table_area.y + 1 + (index * (row_height + ROW_GAP)) + 1
256
+ break if top >= bottom
257
+
258
+ statuses(row).each_with_index do |text, line|
259
+ y = top + line
260
+ break if y >= bottom
261
+
262
+ modifiers = line.zero? ? [:bold] : []
263
+ frame.render_widget(
264
+ tui.paragraph(text: ellipsize(text, width), style: tui.style(fg: :yellow, modifiers:)),
265
+ tui.rect(x: table_area.x + ACTIVITY_INDENT, y:, width:, height: 1)
266
+ )
267
+ end
268
+ end
269
+ end
270
+
271
+ # @param text [String]
272
+ # @param width [Integer] cells available
273
+ # @return [String] the text, or its head and an ellipsis within +width+
274
+ def ellipsize(text, width) = text.length > width ? "#{text[0, width - 1]}…" : text
275
+
276
+ # @param row [Agentilda::Engine::Board::Row]
277
+ # @return [Array<String>] newest first, at most {#scroll_height}
278
+ def statuses(row)
279
+ lines = row.history.empty? ? [row.message.to_s] : row.history
280
+ lines.first(scroll_height)
281
+ end
282
+
283
+ # @return [RatatuiRuby::Widgets::Row]
284
+ def table_row(tui, row)
285
+ tui.row(height: row_height,
286
+ bottom_margin: ROW_GAP,
287
+ cells: [
288
+ row.at.strftime("%H:%M:%S"),
289
+ row.ordinal,
290
+ row.file,
291
+ tui.text_span(content: row.agent, style: tui.style(fg: :yellow, modifiers: [:bold])),
292
+ "R:#{row.round}/#{row.rounds}",
293
+ row.model.to_s,
294
+ row.subagents.to_s,
295
+ "↑#{UI.abbreviate(row.up)} ↓#{UI.abbreviate(row.down)}",
296
+ Bar.cell(tui, row.elapsed, Bar.elapsed_color(row.elapsed)),
297
+ Bar.cell(tui, row.remaining, Bar.remaining_color(row.remaining))
298
+ ])
299
+ end
300
+
301
+ # @return [void]
302
+ def render_overlay(tui, frame, area, board)
303
+ text = board.dialog || board.help || board.about or return
304
+ title = if board.dialog
305
+ "Agent"
306
+ else
307
+ (board.help ? "Keys" : "About")
308
+ end
309
+ lines = text.lines.size + 4
310
+ width = [text.lines.map { |l| l.chomp.length }.max.to_i + 6, area.width].min
311
+ overlay_area = tui.rect(x: [(area.width - width) / 2, 0].max,
312
+ y: [(area.height - lines) / 2, 0].max,
313
+ width:,
314
+ height: lines)
315
+ frame.render_widget(tui.paragraph(text:, block: tui.block(title:, borders: [:all])), overlay_area)
316
+ end
317
+ end
318
+ end
319
+ end
320
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ module Presentation
5
+ # Hands Markdown files to a renderer outside this process: the system
6
+ # viewer via `open`, or mdfried in the terminal.
7
+ #
8
+ # Both paths shell out, so the class takes its two effects as lambdas and
9
+ # the specs assert on the commands instead of launching viewers on the box
10
+ # running the suite.
11
+ class Viewer
12
+ # What to run when mdfried is asked for but not installed. Homebrew is the
13
+ # one package manager the toolchain already assumes (see the Brewfile
14
+ # convention), so there is no fallback chain to get wrong.
15
+ INSTALL = "brew install mdfried >/dev/null"
16
+
17
+ # How to notice mdfried is missing without depending on `which`, which
18
+ # differs across shells; `command -v` is POSIX.
19
+ DETECT = "command -v mdfried >/dev/null"
20
+
21
+ # @param launch [#call] runs a command, returns truthy on success
22
+ # @param pipe [#call] runs a command, yielding its stdin as an IO
23
+ def initialize(launch: ->(*cmd) { system(*cmd) },
24
+ pipe: ->(cmd, &block) { IO.popen(cmd, "w", &block) })
25
+ @launch = launch
26
+ @pipe = pipe
27
+ end
28
+
29
+ # Opens each file in whatever the system has configured for Markdown.
30
+ #
31
+ # @param paths [Array<String>]
32
+ # @return [void]
33
+ def open(paths)
34
+ Array(paths).each { |path| @launch.call("open", path) }
35
+ end
36
+
37
+ # Streams each file's content into mdfried, installing it first when it
38
+ # is missing.
39
+ #
40
+ # @param paths [Array<String>]
41
+ # @return [void]
42
+ # @raise [Agentilda::Error] when mdfried is absent and brew cannot fix that
43
+ def mdfried(paths)
44
+ ensure_mdfried!
45
+ Array(paths).each do |path|
46
+ @pipe.call("mdfried") { |io| io.write(File.read(path)) }
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # @return [void]
53
+ # @raise [Agentilda::Error]
54
+ def ensure_mdfried!
55
+ return if @launch.call(DETECT)
56
+
57
+ @launch.call(INSTALL) or
58
+ raise Error, "mdfried is not installed, and `brew install mdfried` failed"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # Everything a person reads: the live dashboard, the status table, the
5
+ # generated conventions and the state diagram. These render strings and
6
+ # draw on STDERR; none of them changes a plan.
7
+ module Presentation
8
+ module_function
9
+
10
+ # @return [String] the conventions document, derived
11
+ def documentation = Documentation.new.render
12
+
13
+ # @return [String] the state machine, drawn for a terminal
14
+ def diagram = Diagram.new.render
15
+
16
+ # @param tree [Agentilda::Plans::Tree]
17
+ # @return [Agentilda::Presentation::Reporter]
18
+ def reporter(tree:) = Reporter.new(tree:)
19
+
20
+ # @return [Agentilda::Presentation::Viewer]
21
+ def viewer = Viewer.new
22
+ end
23
+ end