agentilda 1.0.3
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 +7 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +261 -0
- data/agentilda.gemspec +57 -0
- data/agents/hansolo-reviewer.md +29 -0
- data/agents/lando-broker.md +74 -0
- data/agents/leah-researcher.md +80 -0
- data/agents/luke-backend.md +81 -0
- data/agents/palpatine-planner.md +40 -0
- data/agents/rey-frontend.md +106 -0
- data/agents/yoda-writer.md +54 -0
- data/bin/create-plan-folder +125 -0
- data/bin/plan-number +164 -0
- data/exe/agentilda +111 -0
- data/exe/tilda +1 -0
- data/lib/agentilda/adoption.rb +192 -0
- data/lib/agentilda/agent.rb +136 -0
- data/lib/agentilda/brief.rb +234 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
- data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
- data/lib/agentilda/cli/base.rb +88 -0
- data/lib/agentilda/cli/create/create.rb +309 -0
- data/lib/agentilda/cli/docs/docs.rb +30 -0
- data/lib/agentilda/cli/index/index.rb +38 -0
- data/lib/agentilda/cli/linear/linear.rb +35 -0
- data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
- data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
- data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
- data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
- data/lib/agentilda/cli/run/run.rb +289 -0
- data/lib/agentilda/cli/states/states.rb +15 -0
- data/lib/agentilda/cli/unblock/unblock.rb +227 -0
- data/lib/agentilda/cli/version/version.rb +13 -0
- data/lib/agentilda/cli.rb +74 -0
- data/lib/agentilda/config.rb +44 -0
- data/lib/agentilda/control.rb +115 -0
- data/lib/agentilda/creator.rb +120 -0
- data/lib/agentilda/dev_work.rb +54 -0
- data/lib/agentilda/diagram.rb +144 -0
- data/lib/agentilda/documentation.rb +429 -0
- data/lib/agentilda/executor.rb +539 -0
- data/lib/agentilda/feature.rb +253 -0
- data/lib/agentilda/frontmatter.rb +36 -0
- data/lib/agentilda/github.rb +160 -0
- data/lib/agentilda/index.rb +206 -0
- data/lib/agentilda/keyboard.rb +88 -0
- data/lib/agentilda/linear/api.rb +220 -0
- data/lib/agentilda/linear/attribution.rb +185 -0
- data/lib/agentilda/linear/fuzzy.rb +68 -0
- data/lib/agentilda/linear/import.rb +298 -0
- data/lib/agentilda/linear/issue.rb +184 -0
- data/lib/agentilda/linear/mapping.rb +115 -0
- data/lib/agentilda/linear/push.rb +190 -0
- data/lib/agentilda/linear/survey.rb +173 -0
- data/lib/agentilda/linear/unit.rb +274 -0
- data/lib/agentilda/linear.rb +42 -0
- data/lib/agentilda/markdown.rb +56 -0
- data/lib/agentilda/ordinal.rb +90 -0
- data/lib/agentilda/progress_log.rb +122 -0
- data/lib/agentilda/publisher.rb +172 -0
- data/lib/agentilda/pull_request.rb +213 -0
- data/lib/agentilda/reporter.rb +175 -0
- data/lib/agentilda/resync.rb +358 -0
- data/lib/agentilda/roster.rb +110 -0
- data/lib/agentilda/runner.rb +456 -0
- data/lib/agentilda/state_machine.rb +355 -0
- data/lib/agentilda/status.rb +280 -0
- data/lib/agentilda/tally.rb +169 -0
- data/lib/agentilda/transcript.rb +435 -0
- data/lib/agentilda/tree.rb +77 -0
- data/lib/agentilda/ui.rb +681 -0
- data/lib/agentilda/unblocker.rb +207 -0
- data/lib/agentilda/version.rb +10 -0
- data/lib/agentilda/viewer.rb +60 -0
- data/lib/agentilda/worktree.rb +211 -0
- data/lib/agentilda.rb +155 -0
- data/lib/dry/cli/banner.rb +293 -0
- metadata +349 -0
data/lib/agentilda/ui.rb
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty/spinner/multi"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
# Everything the user sees that is not the deliverable itself.
|
|
7
|
+
#
|
|
8
|
+
# Include it and you get `info`, `warn`, `error` and `success` as instance
|
|
9
|
+
# methods, each drawing a TTY::Box on **STDERR**. STDERR is deliberate: the
|
|
10
|
+
# documents and tables these commands produce own STDOUT, so every command
|
|
11
|
+
# composes in a pipe.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# class Thing
|
|
15
|
+
# include Agentilda::UI
|
|
16
|
+
# def run = success("Linked 4 skills")
|
|
17
|
+
# end
|
|
18
|
+
module UI
|
|
19
|
+
# Widest a box may be drawn, regardless of how wide the terminal is.
|
|
20
|
+
MAX_WIDTH = 100
|
|
21
|
+
|
|
22
|
+
# Narrowest, so a small terminal still produces readable boxes.
|
|
23
|
+
MIN_WIDTH = 60
|
|
24
|
+
|
|
25
|
+
# Below this many items a progress bar is noise: it appears and vanishes
|
|
26
|
+
# before the eye resolves it, and the line it prints is longer than the work.
|
|
27
|
+
PROGRESS_THRESHOLD = 3
|
|
28
|
+
|
|
29
|
+
# What an item contributes to its log line's columns when its caller has
|
|
30
|
+
# nothing to say about it. A round header is such an item, and it still
|
|
31
|
+
# has to line up with the agent lines under it.
|
|
32
|
+
NO_FIELDS = ->(_item) { {} }
|
|
33
|
+
|
|
34
|
+
# What an item's countdown starts from when its caller has no opinion:
|
|
35
|
+
# nothing, so no timer is drawn. A line without a deadline showing 0:00
|
|
36
|
+
# forever would read as an agent perpetually out of time.
|
|
37
|
+
NO_TIMEOUT = ->(_item) {}
|
|
38
|
+
|
|
39
|
+
# The countdown turns red with this many seconds left — late enough to
|
|
40
|
+
# stay calm through a normal run, early enough to look up before the
|
|
41
|
+
# executor pulls the plug.
|
|
42
|
+
TIMER_WARNING = 60
|
|
43
|
+
|
|
44
|
+
# How a block's RETURN VALUE is judged when its caller has no opinion:
|
|
45
|
+
# nothing is ever a failure. The distinction exists because {Runner}'s
|
|
46
|
+
# executor reports failure by returning a not-ok result rather than by
|
|
47
|
+
# raising — and a line that drew ✓ "done" over a timed-out agent, while
|
|
48
|
+
# the round table under it said FAIL, was the contradiction this closes.
|
|
49
|
+
NO_FAILURE = ->(_result) {}
|
|
50
|
+
|
|
51
|
+
# One item's spinner line, and the same news written to the log.
|
|
52
|
+
#
|
|
53
|
+
# These are two readers of one story and used to be told it separately:
|
|
54
|
+
# the spinner got the phrase, the log got a start and a finish, and an
|
|
55
|
+
# animated run wrote nothing about what any agent was actually doing. A
|
|
56
|
+
# {Agentilda::Transcript::Progress} arrives here several times a
|
|
57
|
+
# second; the spinner is redrawn every time, and the log takes a line only
|
|
58
|
+
# when the phrase itself changes, which is a few dozen times an agent.
|
|
59
|
+
class Line
|
|
60
|
+
# @param fields [Hash] plan, status and agent, for the log's columns
|
|
61
|
+
# @param spinner [TTY::Spinner, nil] nil where nothing is being drawn
|
|
62
|
+
# @param mark [String] what the spinner says once the work succeeds
|
|
63
|
+
# @param timeout [Integer, nil] seconds until the executor abandons
|
|
64
|
+
# this agent; drawn as a countdown on the line, nil draws nothing
|
|
65
|
+
def initialize(fields: {}, spinner: nil, mark: "", timeout: nil)
|
|
66
|
+
@fields = fields
|
|
67
|
+
@spinner = spinner
|
|
68
|
+
@mark = mark
|
|
69
|
+
@timeout = timeout
|
|
70
|
+
@started = UI.monotonic
|
|
71
|
+
@phrase = nil
|
|
72
|
+
@pid = nil
|
|
73
|
+
@ticker = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Float] seconds this agent has been alive
|
|
77
|
+
def seconds = UI.monotonic - @started
|
|
78
|
+
|
|
79
|
+
# @return [String] the same, as the log and the report write it
|
|
80
|
+
def alive = "#{seconds.round}s"
|
|
81
|
+
|
|
82
|
+
# @param message [String]
|
|
83
|
+
# @return [void]
|
|
84
|
+
def note(message) = UI.log(message, **@fields, seconds:)
|
|
85
|
+
|
|
86
|
+
# @return [void]
|
|
87
|
+
def start
|
|
88
|
+
@spinner&.update(pid: identity)
|
|
89
|
+
tick
|
|
90
|
+
note("started")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# What remains on this agent's clock, or nil when it has none. Floored
|
|
94
|
+
# at zero: the executor's kill and this thread's wake-up race by up to
|
|
95
|
+
# a second, and a line reading `-0:01` accuses the wrong party.
|
|
96
|
+
#
|
|
97
|
+
# @return [Integer, nil]
|
|
98
|
+
def remaining
|
|
99
|
+
return nil unless @timeout
|
|
100
|
+
|
|
101
|
+
[@timeout - seconds, 0].max.round
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The countdown, redrawn once a second on its own thread. Progress
|
|
105
|
+
# updates cannot drive it — they arrive only while the agent is
|
|
106
|
+
# talking, and a stalled agent is exactly when the clock matters most.
|
|
107
|
+
#
|
|
108
|
+
# @return [void]
|
|
109
|
+
def tick
|
|
110
|
+
return unless @spinner && @timeout
|
|
111
|
+
|
|
112
|
+
@spinner.update(timer: UI.countdown(remaining))
|
|
113
|
+
@ticker ||= Thread.new do
|
|
114
|
+
loop do
|
|
115
|
+
sleep(1)
|
|
116
|
+
left = remaining
|
|
117
|
+
@spinner.update(timer: UI.countdown(left))
|
|
118
|
+
break unless left.positive?
|
|
119
|
+
end
|
|
120
|
+
rescue
|
|
121
|
+
# A dying spinner must not take the round down with it.
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# @return [void]
|
|
126
|
+
def stop_ticker
|
|
127
|
+
@ticker&.kill
|
|
128
|
+
@ticker = nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# What sits between the agent's name and its activity: the `claude`
|
|
132
|
+
# process and the round, `[36123, round 01]`, so a line on the screen
|
|
133
|
+
# can be matched to a process in `ps` and a row in the report. The pid
|
|
134
|
+
# is unknowable until the child has been spawned and found, so it reads
|
|
135
|
+
# `…` until then; a caller with neither fact gets nothing at all.
|
|
136
|
+
#
|
|
137
|
+
# @return [String]
|
|
138
|
+
def identity
|
|
139
|
+
round = @fields[:round]
|
|
140
|
+
return "" if round.nil? && @pid.nil?
|
|
141
|
+
|
|
142
|
+
inner = [(@pid || "…").to_s, ("round #{round}" if round)].compact.join(", ")
|
|
143
|
+
UI.paint("[#{inner}]", :bright_black)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [void]
|
|
147
|
+
def done
|
|
148
|
+
stop_ticker
|
|
149
|
+
@spinner&.success(@mark)
|
|
150
|
+
note("finished after #{alive}")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @param reason [String]
|
|
154
|
+
# @return [void]
|
|
155
|
+
def failed(reason)
|
|
156
|
+
stop_ticker
|
|
157
|
+
@spinner&.error(UI.paint(reason, :red))
|
|
158
|
+
note("failed after #{alive}: #{reason}")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @param update [Agentilda::Transcript::Progress]
|
|
162
|
+
# @return [void]
|
|
163
|
+
def call(update)
|
|
164
|
+
if update.respond_to?(:pid) && update.pid && update.pid != @pid
|
|
165
|
+
@pid = update.pid
|
|
166
|
+
@spinner&.update(pid: identity)
|
|
167
|
+
note("claude is pid #{@pid}")
|
|
168
|
+
end
|
|
169
|
+
@spinner&.update(meter: UI.meter(update), activity: UI.said(update.activity))
|
|
170
|
+
return if update.activity.nil? || update.activity == @phrase
|
|
171
|
+
|
|
172
|
+
@phrase = update.activity
|
|
173
|
+
note(update.activity)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# So a caller can pass this straight on as a block: {Executor} yields
|
|
177
|
+
# to it from the thread it reads the agent's stream on.
|
|
178
|
+
#
|
|
179
|
+
# @return [Proc]
|
|
180
|
+
def to_proc = method(:call).to_proc
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
class << self
|
|
184
|
+
# Set by the CLI's --quiet. Silences spinners and bars along with
|
|
185
|
+
# everything else, so a quiet run really is quiet.
|
|
186
|
+
#
|
|
187
|
+
# @return [Boolean]
|
|
188
|
+
attr_accessor :quiet
|
|
189
|
+
|
|
190
|
+
# @return [Pastel] colour engine, disabled when STDERR is not a terminal
|
|
191
|
+
def pastel = @pastel ||= Pastel.new(enabled: color?)
|
|
192
|
+
|
|
193
|
+
# Forget everything memoized here.
|
|
194
|
+
#
|
|
195
|
+
# Pastel captures `enabled:` once, at construction. Anything that changes
|
|
196
|
+
# the answer afterwards — a test stubbing `tty?`, a caller setting
|
|
197
|
+
# NO_COLOR late — would otherwise be ignored for the rest of the process,
|
|
198
|
+
# and the first answer would leak into every later call.
|
|
199
|
+
#
|
|
200
|
+
# @return [void]
|
|
201
|
+
def reset!
|
|
202
|
+
remove_instance_variable(:@pastel) if instance_variable_defined?(:@pastel)
|
|
203
|
+
self.quiet = false
|
|
204
|
+
self.log_path = nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Where {.log} appends to, if anywhere. nil (the default) means nowhere;
|
|
208
|
+
# `agentilda run` sets this before the loop starts, so a round
|
|
209
|
+
# started under a tool that discards STDERR — an agent's own Bash call,
|
|
210
|
+
# for instance — still leaves something to `tail -f`.
|
|
211
|
+
#
|
|
212
|
+
# @return [String, nil]
|
|
213
|
+
attr_accessor :log_path
|
|
214
|
+
|
|
215
|
+
# Append one timestamped line to {.log_path}. A no-op with nothing set.
|
|
216
|
+
# Safe to call from several threads at once.
|
|
217
|
+
#
|
|
218
|
+
# @param message [String]
|
|
219
|
+
# @return [void]
|
|
220
|
+
def log(message, **fields)
|
|
221
|
+
path = log_path or return
|
|
222
|
+
|
|
223
|
+
line = ProgressLog.render(message, **fields)
|
|
224
|
+
(@log_mutex ||= Mutex.new).synchronize do
|
|
225
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
226
|
+
File.open(path, "a") { |f| f.puts(line) }
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Cells the meter takes on a spinner line, per direction.
|
|
231
|
+
#
|
|
232
|
+
# Fixed, and padded to it, because the numbers grow as the agent works and
|
|
233
|
+
# a column that sizes itself to them drags the whole line sideways every
|
|
234
|
+
# few seconds.
|
|
235
|
+
METER_WIDTH = 7
|
|
236
|
+
|
|
237
|
+
# The token counter that sits between the spinner and the agent's name.
|
|
238
|
+
#
|
|
239
|
+
# Up is everything sent, cache reads included, which is most of it. Down
|
|
240
|
+
# is what the model generated. Sub-agent spend is folded into up, since
|
|
241
|
+
# `claude` reports a sub-agent's total without splitting it.
|
|
242
|
+
#
|
|
243
|
+
# @param update [Agentilda::Transcript::Progress, nil]
|
|
244
|
+
# @return [String]
|
|
245
|
+
def meter(update)
|
|
246
|
+
paint(fit("↑#{abbreviate(update&.up)}", METER_WIDTH), :bright_blue) +
|
|
247
|
+
paint(fit("↓#{abbreviate(update&.down)}", METER_WIDTH), :bright_magenta)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Cells the countdown takes, `MM:SS ` included — the run default of
|
|
251
|
+
# 900s reads `15:00`, and padding to a fixed width keeps the columns
|
|
252
|
+
# to its right from stepping sideways once `9:59` loses a digit.
|
|
253
|
+
TIMER_WIDTH = 6
|
|
254
|
+
|
|
255
|
+
# The countdown that sits between the spinner and the meter: what is
|
|
256
|
+
# left of the agent's timeout, quiet grey until the last
|
|
257
|
+
# {TIMER_WARNING} seconds, red from there down.
|
|
258
|
+
#
|
|
259
|
+
# @param left [Integer, nil] seconds remaining; nil draws nothing
|
|
260
|
+
# @return [String]
|
|
261
|
+
def countdown(left)
|
|
262
|
+
return "" if left.nil?
|
|
263
|
+
|
|
264
|
+
text = fit(format("%d:%02d", left / 60, left % 60), TIMER_WIDTH)
|
|
265
|
+
paint(text, (left <= TIMER_WARNING) ? :red : :bright_black)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Token counts run to seven figures, and seven figures on a spinner line
|
|
269
|
+
# is four cells of noise about a number nobody reads to the digit.
|
|
270
|
+
#
|
|
271
|
+
# @param count [Integer]
|
|
272
|
+
# @return [String] e.g. "512", "4.9k", "121k", "1.6M"
|
|
273
|
+
def abbreviate(count)
|
|
274
|
+
count = count.to_i
|
|
275
|
+
case count
|
|
276
|
+
when 0...1_000 then count.to_s
|
|
277
|
+
when 1_000...10_000 then "#{(count / 1000.0).round(1)}k"
|
|
278
|
+
when 10_000...1_000_000 then "#{(count / 1000.0).round}k"
|
|
279
|
+
when 1_000_000...10_000_000 then "#{(count / 1_000_000.0).round(1)}M"
|
|
280
|
+
else "#{(count / 1_000_000.0).round}M"
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# @param phrase [String, nil]
|
|
285
|
+
# @return [String] the phrase as a spinner line carries it
|
|
286
|
+
def said(phrase) = phrase.to_s.empty? ? "" : paint(": #{phrase}", :green, :bold)
|
|
287
|
+
|
|
288
|
+
# @return [Boolean] whether STDERR is an interactive terminal
|
|
289
|
+
def tty? = $stderr.tty?
|
|
290
|
+
|
|
291
|
+
# Whether animated output is worth drawing at all. A pipe, a CI log or a
|
|
292
|
+
# --quiet run gets none: spinner frames written to a file are line noise.
|
|
293
|
+
#
|
|
294
|
+
# @return [Boolean]
|
|
295
|
+
def animate? = tty? && !quiet
|
|
296
|
+
|
|
297
|
+
# Indeterminate work — one call whose duration cannot be predicted, such
|
|
298
|
+
# as a network round trip. The spinner runs until the block returns.
|
|
299
|
+
#
|
|
300
|
+
# @param message [String] what is being waited on
|
|
301
|
+
# @yieldreturn [Object] whatever the work produces
|
|
302
|
+
# @return [Object] the block's value, untouched
|
|
303
|
+
def spinning(message)
|
|
304
|
+
return yield(logging_activity(message)) unless animate?
|
|
305
|
+
|
|
306
|
+
spinner = TTY::Spinner.new("[:spinner] #{message}:activity", format: :dots, output: $stderr,
|
|
307
|
+
success_mark: paint("✓", :green), error_mark: paint("✖", :red))
|
|
308
|
+
spinner.update(activity: "")
|
|
309
|
+
spinner.auto_spin
|
|
310
|
+
begin
|
|
311
|
+
result = yield(activity_for(spinner))
|
|
312
|
+
spinner.success(paint("done", :bright_black))
|
|
313
|
+
result
|
|
314
|
+
rescue
|
|
315
|
+
spinner.error(paint("failed", :red))
|
|
316
|
+
raise
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Determinate work — N items of roughly equal cost. Yields each item and
|
|
321
|
+
# advances the bar; returns the collection so it can be chained.
|
|
322
|
+
#
|
|
323
|
+
# @param items [Array]
|
|
324
|
+
# @param message [String]
|
|
325
|
+
# @yieldparam item [Object]
|
|
326
|
+
# @return [Array] +items+
|
|
327
|
+
def stepping(items, message, &)
|
|
328
|
+
list = items.to_a
|
|
329
|
+
return list.each(&) unless animate? && list.size >= PROGRESS_THRESHOLD
|
|
330
|
+
|
|
331
|
+
bar = TTY::ProgressBar.new(
|
|
332
|
+
"#{message} [:bar] :current/:total :percent",
|
|
333
|
+
total: list.size, output: $stderr, width: 24,
|
|
334
|
+
complete: "█", incomplete: "░", head: "█"
|
|
335
|
+
)
|
|
336
|
+
list.each do |item|
|
|
337
|
+
yield item
|
|
338
|
+
bar.advance
|
|
339
|
+
end
|
|
340
|
+
bar.finish
|
|
341
|
+
list
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Run a block over many items at once, one spinner each.
|
|
345
|
+
#
|
|
346
|
+
# This is the shape for work that is independent and slow: each item gets
|
|
347
|
+
# its own line, its own thread and its own success or failure mark, so a
|
|
348
|
+
# long round reads as progress rather than as a hang.
|
|
349
|
+
#
|
|
350
|
+
# Results come back in the order the items were given, not the order they
|
|
351
|
+
# finished — a caller that had to re-sort them would be a caller that
|
|
352
|
+
# eventually forgets to.
|
|
353
|
+
#
|
|
354
|
+
# Every path here — one item, several without a terminal, several with
|
|
355
|
+
# one — reports something. `jobs <= 1 || list.size <= 1` used to bypass
|
|
356
|
+
# all of it and run silently, which is exactly the shape a `--plan
|
|
357
|
+
# NNN.MM` round takes: one plan, one agent, nothing printed until the
|
|
358
|
+
# whole thing finished and it was too late to tell "working" from "hung."
|
|
359
|
+
#
|
|
360
|
+
# @param items [Array]
|
|
361
|
+
# @param message [String] the header line
|
|
362
|
+
# @param jobs [Integer] how many run at once
|
|
363
|
+
# @param label [Proc] item -> the text on its line
|
|
364
|
+
# @param failure [Proc] the block's return value -> a reason when that
|
|
365
|
+
# value reports a failure, nil when it reports success. The block
|
|
366
|
+
# returning normally is not the same fact as the work having worked.
|
|
367
|
+
# @param header [Hash] log columns for the header line itself, so the
|
|
368
|
+
# line announcing a round carries the same round number as the agent
|
|
369
|
+
# lines under it rather than a blank cell
|
|
370
|
+
# @yieldparam item [Object]
|
|
371
|
+
# @return [Array] one result per item, in input order
|
|
372
|
+
def concurrently(items, message, jobs:, label: :to_s.to_proc, fields: NO_FIELDS,
|
|
373
|
+
failure: NO_FAILURE, header: {}, timeout: NO_TIMEOUT, &block)
|
|
374
|
+
list = items.to_a
|
|
375
|
+
return [] if list.empty?
|
|
376
|
+
|
|
377
|
+
log(message, **header)
|
|
378
|
+
|
|
379
|
+
if jobs <= 1 || list.size <= 1
|
|
380
|
+
report_line(message) unless animate?
|
|
381
|
+
return list.map { |item| once(item, label, fields, failure:, timeout:, &block) }
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
return threaded(list, jobs, message, label:, fields:, failure:, &block) unless animate?
|
|
385
|
+
|
|
386
|
+
results = Concurrent::Hash.new
|
|
387
|
+
spinners = TTY::Spinner::Multi.new(
|
|
388
|
+
":spinner #{paint(message, :bold)}",
|
|
389
|
+
format: :dots, output: $stderr,
|
|
390
|
+
success_mark: paint("✓", :green), error_mark: paint("✖", :red)
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
list.each_with_index do |item, index|
|
|
394
|
+
text = label.call(item)
|
|
395
|
+
child = spinners.register("[:spinner] :timer:meter#{text}:pid:activity") do |spinner|
|
|
396
|
+
line = Line.new(fields: fields.call(item), spinner:, timeout: timeout.call(item))
|
|
397
|
+
line.start
|
|
398
|
+
result = results[index] = block.call(item, line)
|
|
399
|
+
if (reason = failure.call(result))
|
|
400
|
+
line.failed(reason)
|
|
401
|
+
else
|
|
402
|
+
line.done
|
|
403
|
+
end
|
|
404
|
+
rescue => e
|
|
405
|
+
results[index] = e
|
|
406
|
+
line&.failed(e.message.lines.first.to_s.strip)
|
|
407
|
+
end
|
|
408
|
+
# An unset token renders as the literal `:activity`, so every line
|
|
409
|
+
# says so until its agent gets far enough to have news. The meter
|
|
410
|
+
# starts at zero for the same reason, and because a counter that
|
|
411
|
+
# appears once the first number arrives shifts the whole line.
|
|
412
|
+
child.update(timer: "", meter: meter(nil), activity: "", pid: "")
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
spinners.auto_spin
|
|
416
|
+
list.each_index.map { |i| results[i] }
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# The same news, with no spinner to put it on. A piped or CI run still
|
|
420
|
+
# wants it, in the log where the rest of that run's progress goes.
|
|
421
|
+
#
|
|
422
|
+
# @param text [String] the item's label
|
|
423
|
+
# @return [Proc] phrase -> void
|
|
424
|
+
def logging_activity(text) = ->(phrase) { log("#{text}: #{phrase}") }
|
|
425
|
+
|
|
426
|
+
# A callable that writes what an agent is doing onto its own spinner line.
|
|
427
|
+
#
|
|
428
|
+
# The `:activity` token is empty until something calls this, so a line
|
|
429
|
+
# reads as it always did until there is news. It is written from the
|
|
430
|
+
# reader thread the command's output arrives on, which is why the token
|
|
431
|
+
# is replaced whole rather than appended to.
|
|
432
|
+
#
|
|
433
|
+
# @param spinner [TTY::Spinner]
|
|
434
|
+
# @return [Proc] phrase -> void
|
|
435
|
+
def activity_for(spinner)
|
|
436
|
+
lambda { |phrase|
|
|
437
|
+
spinner.update(activity: phrase.to_s.empty? ? "" : paint(": #{phrase}", :green, :bold))
|
|
438
|
+
}
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# One item, no concurrency to speak of: a serial round (`--isolation
|
|
442
|
+
# shared`), or the last plan left in a parallel one. A live spinner on a
|
|
443
|
+
# terminal; a start line and a finish line with an elapsed time otherwise.
|
|
444
|
+
#
|
|
445
|
+
# @param item [Object]
|
|
446
|
+
# @param label [Proc]
|
|
447
|
+
# @yieldparam item [Object]
|
|
448
|
+
# @return [Object]
|
|
449
|
+
def once(item, label, fields = NO_FIELDS, failure: NO_FAILURE, timeout: NO_TIMEOUT, &block)
|
|
450
|
+
text = label.call(item)
|
|
451
|
+
line = Line.new(fields: fields.call(item), mark: paint("done", :bright_black),
|
|
452
|
+
timeout: timeout.call(item), spinner: (solo_spinner(text) if animate?))
|
|
453
|
+
line.start
|
|
454
|
+
begin
|
|
455
|
+
result = block.call(item, line)
|
|
456
|
+
rescue => e
|
|
457
|
+
reason = e.message.lines.first.to_s.strip
|
|
458
|
+
line.failed(reason)
|
|
459
|
+
report_line("#{text}: #{reason}", bullet: "✗") unless animate?
|
|
460
|
+
raise
|
|
461
|
+
end
|
|
462
|
+
if (reason = failure.call(result))
|
|
463
|
+
line.failed(reason)
|
|
464
|
+
report_line("#{text}: #{reason}", bullet: "✗") unless animate?
|
|
465
|
+
else
|
|
466
|
+
line.done
|
|
467
|
+
report_line("#{text} (#{line.alive})", bullet: "✓") unless animate?
|
|
468
|
+
end
|
|
469
|
+
result
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# The spinner a lone agent gets. Registered nowhere, because there is no
|
|
473
|
+
# second line for it to line up with.
|
|
474
|
+
#
|
|
475
|
+
# @param text [String]
|
|
476
|
+
# @return [TTY::Spinner]
|
|
477
|
+
def solo_spinner(text)
|
|
478
|
+
spinner = TTY::Spinner.new("[:spinner] :timer:meter#{text}:pid:activity", format: :dots, output: $stderr,
|
|
479
|
+
success_mark: paint("✓", :green), error_mark: paint("✖", :red))
|
|
480
|
+
spinner.update(timer: "", meter: meter(nil), activity: "", pid: "")
|
|
481
|
+
spinner.auto_spin
|
|
482
|
+
spinner
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Parallelism with no spinner to draw: a pipe, a CI log, or a headless
|
|
486
|
+
# agent's own tool call. Still reports a start and a finish line per
|
|
487
|
+
# item, because "no terminal" is not the same question as "no one is
|
|
488
|
+
# reading this."
|
|
489
|
+
#
|
|
490
|
+
# @param list [Array]
|
|
491
|
+
# @param jobs [Integer]
|
|
492
|
+
# @param message [String]
|
|
493
|
+
# @param label [Proc]
|
|
494
|
+
# @return [Array]
|
|
495
|
+
def threaded(list, jobs, message, label: :to_s.to_proc, fields: NO_FIELDS,
|
|
496
|
+
failure: NO_FAILURE, &)
|
|
497
|
+
report_line(message)
|
|
498
|
+
results = Concurrent::Hash.new
|
|
499
|
+
queue = Queue.new
|
|
500
|
+
list.each_with_index { |item, index| queue << [item, index] }
|
|
501
|
+
|
|
502
|
+
[jobs, list.size].min.times.map {
|
|
503
|
+
Thread.new do
|
|
504
|
+
while (pair = begin
|
|
505
|
+
queue.pop(true)
|
|
506
|
+
rescue ThreadError
|
|
507
|
+
nil
|
|
508
|
+
end)
|
|
509
|
+
item, index = pair
|
|
510
|
+
results[index] = begin
|
|
511
|
+
once(item, label, fields, failure:, &)
|
|
512
|
+
rescue => e
|
|
513
|
+
e
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
}.each(&:join)
|
|
518
|
+
|
|
519
|
+
list.each_index.map { |i| results[i] }
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# @return [Float] a monotonic clock reading, immune to wall-clock changes
|
|
523
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
524
|
+
|
|
525
|
+
# @param started [Float] a {#monotonic} reading taken before the work began
|
|
526
|
+
# @return [String] e.g. "42s"
|
|
527
|
+
def elapsed(started) = "#{(monotonic - started).round}s"
|
|
528
|
+
|
|
529
|
+
# A progress line, thread-safe and quiet-aware — the one thing every
|
|
530
|
+
# non-spinner path above needs and would otherwise have to reimplement.
|
|
531
|
+
#
|
|
532
|
+
# @param text [String]
|
|
533
|
+
# @param bullet [String]
|
|
534
|
+
# @return [void]
|
|
535
|
+
def report_line(text, bullet: "·")
|
|
536
|
+
return if quiet
|
|
537
|
+
|
|
538
|
+
(@print_mutex ||= Mutex.new).synchronize { line(text, bullet:) }
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# A sensible worker count: agents are mostly waiting on a model rather
|
|
542
|
+
# than burning CPU, so this is deliberately close to the core count. Two
|
|
543
|
+
# are left for the machine, and the cap keeps a very large tree from
|
|
544
|
+
# opening fifty subprocesses at once.
|
|
545
|
+
#
|
|
546
|
+
# @return [Integer]
|
|
547
|
+
def default_jobs = (Etc.nprocessors - 2).clamp(1, 12)
|
|
548
|
+
|
|
549
|
+
# Honours NO_COLOR — https://no-color.org
|
|
550
|
+
#
|
|
551
|
+
# @return [Boolean]
|
|
552
|
+
def color? = tty? && !ENV.key?("NO_COLOR")
|
|
553
|
+
|
|
554
|
+
# @return [Integer] a box width that fits the terminal but stays readable
|
|
555
|
+
def width = (TTY::Screen.width - 4).clamp(MIN_WIDTH, MAX_WIDTH)
|
|
556
|
+
|
|
557
|
+
# @param text [String]
|
|
558
|
+
# @param styles [Array<Symbol>] pastel style names
|
|
559
|
+
# @return [String] decorated when colour is on, bare otherwise
|
|
560
|
+
def paint(text, *styles) = color? ? pastel.decorate(text.to_s, *styles) : text.to_s
|
|
561
|
+
|
|
562
|
+
# @param text [String]
|
|
563
|
+
# @return [Integer] how many terminal cells the text occupies
|
|
564
|
+
def display_width(text) = Unicode::DisplayWidth.of(text.to_s)
|
|
565
|
+
|
|
566
|
+
# Pad or truncate to an exact number of terminal *cells*.
|
|
567
|
+
#
|
|
568
|
+
# `format`'s "%-20.20s" counts characters, and a character is not a cell.
|
|
569
|
+
# "✅" is one character two cells wide; "🅱️" is two characters one cell
|
|
570
|
+
# wide. Any column laid out with %s therefore drifts by one for every
|
|
571
|
+
# emoji whose two counts disagree — which is every emoji, in one
|
|
572
|
+
# direction or the other.
|
|
573
|
+
#
|
|
574
|
+
# @param text [String] unpainted; escape codes count as characters and
|
|
575
|
+
# would be padded like any other
|
|
576
|
+
# @param width [Integer] terminal cells
|
|
577
|
+
# @return [String]
|
|
578
|
+
def fit(text, width)
|
|
579
|
+
text = text.to_s
|
|
580
|
+
text = text[0..-2] while display_width(text) > width
|
|
581
|
+
text + (" " * (width - display_width(text)))
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Everything the user sees goes through here, so it writes to `$stderr`
|
|
585
|
+
# directly rather than through `Kernel.warn`.
|
|
586
|
+
#
|
|
587
|
+
# That is not a style preference. `Kernel.warn` is a **no-op** when
|
|
588
|
+
# `$VERBOSE` is nil, which is what `-W0` sets — and `RUBYOPT=-W0` is
|
|
589
|
+
# common in CI images and agent harnesses. Routed through `Kernel.warn`,
|
|
590
|
+
# every box this tool draws silently disappears in exactly the
|
|
591
|
+
# environments where a failure most needs explaining.
|
|
592
|
+
#
|
|
593
|
+
# @param kind [Symbol] :info, :warn, :error or :success
|
|
594
|
+
# @param message [String]
|
|
595
|
+
# @return [void]
|
|
596
|
+
# standard:disable Style/StderrPuts -- the cop's own rationale, "to allow
|
|
597
|
+
# such output to be disabled", is the behaviour being removed here.
|
|
598
|
+
def box(kind, message)
|
|
599
|
+
text = message.to_s
|
|
600
|
+
$stderr.puts TTY::Box.public_send(kind, text, enable_color: color?, width:, height: box_height(text))
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
# standard:enable Style/StderrPuts
|
|
604
|
+
|
|
605
|
+
# A framed panel centered on the screen, for the keyboard help. Unlike
|
|
606
|
+
# {.box} it positions itself absolutely, so it overlays whatever the
|
|
607
|
+
# spinners are drawing rather than scrolling in below them — the next
|
|
608
|
+
# repaint draws over it, which is all the dismissal a help screen needs.
|
|
609
|
+
#
|
|
610
|
+
# @param title [String]
|
|
611
|
+
# @param text [String]
|
|
612
|
+
# @return [void]
|
|
613
|
+
# standard:disable Style/StderrPuts -- see {.box}: `warn` is a no-op under -W0.
|
|
614
|
+
def popup(title, text)
|
|
615
|
+
lines = text.to_s.lines
|
|
616
|
+
box_width = [lines.map { |l| display_width(l.chomp) }.max.to_i + 6, TTY::Screen.width].min
|
|
617
|
+
box_height = lines.size + 4
|
|
618
|
+
$stderr.print TTY::Box.frame(
|
|
619
|
+
top: [(TTY::Screen.height - box_height) / 2, 0].max,
|
|
620
|
+
left: [(TTY::Screen.width - box_width) / 2, 0].max,
|
|
621
|
+
width: box_width, height: box_height, padding: 1,
|
|
622
|
+
title: {top_left: " #{title} "}, enable_color: color?,
|
|
623
|
+
style: color? ? {border: {fg: :cyan}} : {}
|
|
624
|
+
) { text.to_s }
|
|
625
|
+
end
|
|
626
|
+
# standard:enable Style/StderrPuts
|
|
627
|
+
|
|
628
|
+
# TTY::Box sizes itself from the number of lines you hand it, not from
|
|
629
|
+
# the number those lines occupy once wrapped to the box's width. So it
|
|
630
|
+
# draws any message containing a line longer than the box a row or two
|
|
631
|
+
# short, and what falls off is the bottom, which is where the instruction
|
|
632
|
+
# lives. The run that found this reported four of its ten failures and
|
|
633
|
+
# cut the fifth mid-sentence.
|
|
634
|
+
#
|
|
635
|
+
# This wraps with the same library TTY::Box wraps with rather than
|
|
636
|
+
# dividing by the width, because TTY::Box wraps on words. A rough
|
|
637
|
+
# estimate is wrong in exactly the cases this exists for.
|
|
638
|
+
#
|
|
639
|
+
# @param text [String]
|
|
640
|
+
# @return [Integer] rows the box needs: its content, two borders, one pad
|
|
641
|
+
def box_height(text) = Strings.wrap(text.to_s, width - 4).lines.size + 3
|
|
642
|
+
|
|
643
|
+
# A single unadorned line, for per-item progress that does not deserve
|
|
644
|
+
# a box of its own.
|
|
645
|
+
#
|
|
646
|
+
# @param message [String]
|
|
647
|
+
# @param bullet [String]
|
|
648
|
+
# @return [void]
|
|
649
|
+
# standard:disable Style/StderrPuts -- see {.box}: `warn` is a no-op under -W0.
|
|
650
|
+
def line(message, bullet: "·") = $stderr.puts(" #{paint(bullet, :bright_black)} #{message}")
|
|
651
|
+
|
|
652
|
+
# standard:enable Style/StderrPuts
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# @param message [String]
|
|
656
|
+
# @return [void]
|
|
657
|
+
def info(message) = UI.box(:info, message)
|
|
658
|
+
|
|
659
|
+
# @param message [String]
|
|
660
|
+
# @return [void]
|
|
661
|
+
def warn(message) = UI.box(:warn, message)
|
|
662
|
+
|
|
663
|
+
# @param message [String]
|
|
664
|
+
# @return [void]
|
|
665
|
+
def error(message) = UI.box(:error, message)
|
|
666
|
+
|
|
667
|
+
# @param message [String]
|
|
668
|
+
# @return [void]
|
|
669
|
+
def success(message) = UI.box(:success, message)
|
|
670
|
+
|
|
671
|
+
# @param message [String]
|
|
672
|
+
# @param bullet [String]
|
|
673
|
+
# @return [void]
|
|
674
|
+
def say(message, bullet: "·") = UI.line(message, bullet:)
|
|
675
|
+
|
|
676
|
+
# @param text [String]
|
|
677
|
+
# @param styles [Array<Symbol>]
|
|
678
|
+
# @return [String]
|
|
679
|
+
def paint(text, *styles) = UI.paint(text, *styles)
|
|
680
|
+
end
|
|
681
|
+
end
|