agentilda 2.0.3 → 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.
- checksums.yaml +4 -4
- data/lib/agentilda/agents/agent.rb +79 -0
- data/lib/agentilda/agents/registry.rb +121 -0
- data/lib/agentilda/agents/roster.rb +92 -0
- data/lib/agentilda/agents.rb +11 -113
- data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
- data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
- data/lib/agentilda/cli/base.rb +3 -3
- data/lib/agentilda/cli/create/create.rb +17 -18
- data/lib/agentilda/cli/docs/docs.rb +1 -1
- data/lib/agentilda/cli/index/index.rb +1 -1
- data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
- data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
- data/lib/agentilda/cli/mail/mail.rb +4 -4
- data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
- data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
- data/lib/agentilda/cli/run/run.rb +40 -40
- data/lib/agentilda/cli/states/states.rb +1 -1
- data/lib/agentilda/cli/unblock/unblock.rb +17 -17
- data/lib/agentilda/cli/worktree/worktree.rb +4 -4
- data/lib/agentilda/cli.rb +1 -1
- data/lib/agentilda/engine/board.rb +93 -0
- data/lib/agentilda/engine/dispatcher.rb +646 -0
- data/lib/agentilda/engine/progress_log.rb +125 -0
- data/lib/agentilda/engine/runner.rb +141 -0
- data/lib/agentilda/engine/state_file.rb +176 -0
- data/lib/agentilda/engine/tally.rb +177 -0
- data/lib/agentilda/engine.rb +19 -0
- data/lib/agentilda/execution/child.rb +94 -0
- data/lib/agentilda/execution/clock.rb +116 -0
- data/lib/agentilda/execution/control.rb +180 -0
- data/lib/agentilda/execution/executor.rb +699 -0
- data/lib/agentilda/execution/transcript.rb +468 -0
- data/lib/agentilda/execution.rb +18 -0
- data/lib/agentilda/lifecycle/adoption.rb +197 -0
- data/lib/agentilda/lifecycle/brief.rb +285 -0
- data/lib/agentilda/lifecycle/creator.rb +125 -0
- data/lib/agentilda/lifecycle/resync.rb +428 -0
- data/lib/agentilda/lifecycle/unblocker.rb +223 -0
- data/lib/agentilda/lifecycle.rb +28 -0
- data/lib/agentilda/linear/api.rb +1 -1
- data/lib/agentilda/linear/attribution.rb +7 -7
- data/lib/agentilda/linear/import.rb +18 -18
- data/lib/agentilda/linear/mapping.rb +6 -6
- data/lib/agentilda/linear/push.rb +5 -5
- data/lib/agentilda/linear/survey.rb +7 -7
- data/lib/agentilda/linear/unit.rb +5 -5
- data/lib/agentilda/plans/dev_work.rb +56 -0
- data/lib/agentilda/plans/feature.rb +131 -0
- data/lib/agentilda/plans/index.rb +208 -0
- data/lib/agentilda/plans/ledger.rb +235 -0
- data/lib/agentilda/plans/mailbox.rb +143 -0
- data/lib/agentilda/plans/ordinal.rb +92 -0
- data/lib/agentilda/plans/pull_request.rb +29 -0
- data/lib/agentilda/plans/pull_requests.rb +229 -0
- data/lib/agentilda/plans/state_machine.rb +383 -0
- data/lib/agentilda/plans/status.rb +365 -0
- data/lib/agentilda/plans/subject.rb +131 -0
- data/lib/agentilda/plans/tree.rb +79 -0
- data/lib/agentilda/plans.rb +39 -0
- data/lib/agentilda/presentation/console.rb +159 -0
- data/lib/agentilda/presentation/dashboard.rb +221 -0
- data/lib/agentilda/presentation/diagram.rb +149 -0
- data/lib/agentilda/presentation/documentation.rb +439 -0
- data/lib/agentilda/presentation/keyboard.rb +158 -0
- data/lib/agentilda/presentation/reporter.rb +181 -0
- data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
- data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
- data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
- data/lib/agentilda/presentation/viewer.rb +62 -0
- data/lib/agentilda/presentation.rb +23 -0
- data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
- data/lib/agentilda/vcs/github.rb +170 -0
- data/lib/agentilda/vcs/publisher.rb +177 -0
- data/lib/agentilda/vcs/worktree.rb +209 -0
- data/lib/agentilda/vcs.rb +19 -0
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda.rb +6 -5
- metadata +56 -49
- data/lib/agentilda/adoption.rb +0 -197
- data/lib/agentilda/agent.rb +0 -77
- data/lib/agentilda/board.rb +0 -91
- data/lib/agentilda/brief.rb +0 -283
- data/lib/agentilda/child.rb +0 -92
- data/lib/agentilda/clock.rb +0 -114
- data/lib/agentilda/console.rb +0 -157
- data/lib/agentilda/control.rb +0 -178
- data/lib/agentilda/creator.rb +0 -125
- data/lib/agentilda/dashboard.rb +0 -219
- data/lib/agentilda/dev_work.rb +0 -56
- data/lib/agentilda/diagram.rb +0 -149
- data/lib/agentilda/dispatcher.rb +0 -646
- data/lib/agentilda/documentation.rb +0 -437
- data/lib/agentilda/executor.rb +0 -697
- data/lib/agentilda/feature.rb +0 -132
- data/lib/agentilda/github.rb +0 -168
- data/lib/agentilda/index.rb +0 -206
- data/lib/agentilda/keyboard.rb +0 -156
- data/lib/agentilda/ledger.rb +0 -233
- data/lib/agentilda/mailbox.rb +0 -141
- data/lib/agentilda/ordinal.rb +0 -90
- data/lib/agentilda/progress_log.rb +0 -126
- data/lib/agentilda/publisher.rb +0 -175
- data/lib/agentilda/pull_request.rb +0 -27
- data/lib/agentilda/pull_requests.rb +0 -230
- data/lib/agentilda/reporter.rb +0 -181
- data/lib/agentilda/resync.rb +0 -426
- data/lib/agentilda/roster.rb +0 -93
- data/lib/agentilda/runner.rb +0 -139
- data/lib/agentilda/screen/ratatui/bar.rb +0 -57
- data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
- data/lib/agentilda/screen/ratatui.rb +0 -318
- data/lib/agentilda/state_file.rb +0 -174
- data/lib/agentilda/state_machine.rb +0 -383
- data/lib/agentilda/status.rb +0 -363
- data/lib/agentilda/subject.rb +0 -133
- data/lib/agentilda/tally.rb +0 -175
- data/lib/agentilda/transcript.rb +0 -466
- data/lib/agentilda/tree.rb +0 -81
- data/lib/agentilda/unblocker.rb +0 -221
- data/lib/agentilda/viewer.rb +0 -60
- data/lib/agentilda/worktree.rb +0 -207
- /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
- /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
- /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
|