agentilda 1.0.3 → 2.0.0
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/agentilda.gemspec +8 -6
- data/agents/hansolo-reviewer.md +42 -13
- data/agents/lando-broker.md +40 -46
- data/agents/leah-researcher.md +36 -58
- data/agents/luke-backend.md +49 -61
- data/agents/palpatine-planner.md +39 -21
- data/agents/rey-frontend.md +44 -86
- data/agents/yoda-writer.md +38 -36
- data/bin/setup +26 -0
- data/exe/agentilda +13 -92
- data/exe/tilda-rs +0 -0
- data/lib/agentilda/adoption.rb +7 -2
- data/lib/agentilda/agent.rb +40 -99
- data/lib/agentilda/agents.rb +122 -0
- data/lib/agentilda/board.rb +91 -0
- data/lib/agentilda/brief.rb +54 -5
- data/lib/agentilda/child.rb +78 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
- data/lib/agentilda/cli/base.rb +22 -6
- data/lib/agentilda/cli/create/create.rb +46 -19
- data/lib/agentilda/cli/docs/docs.rb +6 -2
- data/lib/agentilda/cli/index/index.rb +6 -4
- data/lib/agentilda/cli/linear/linear.rb +3 -2
- data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
- data/lib/agentilda/cli/mail/mail.rb +31 -0
- data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
- data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
- data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
- data/lib/agentilda/cli/run/run.rb +207 -99
- data/lib/agentilda/cli/unblock/unblock.rb +24 -13
- data/lib/agentilda/cli/worktree/worktree.rb +85 -0
- data/lib/agentilda/cli.rb +43 -0
- data/lib/agentilda/clock.rb +114 -0
- data/lib/agentilda/console.rb +157 -0
- data/lib/agentilda/control.rb +63 -0
- data/lib/agentilda/creator.rb +7 -2
- data/lib/agentilda/dashboard.rb +219 -0
- data/lib/agentilda/dev_work.rb +2 -0
- data/lib/agentilda/diagram.rb +12 -7
- data/lib/agentilda/dispatcher.rb +646 -0
- data/lib/agentilda/documentation.rb +14 -6
- data/lib/agentilda/executor.rb +362 -204
- data/lib/agentilda/feature.rb +14 -135
- data/lib/agentilda/frontmatter.rb +4 -2
- data/lib/agentilda/github.rb +19 -11
- data/lib/agentilda/index.rb +11 -11
- data/lib/agentilda/keyboard.rb +76 -8
- data/lib/agentilda/launcher.rb +69 -0
- data/lib/agentilda/ledger.rb +233 -0
- data/lib/agentilda/linear/api.rb +14 -10
- data/lib/agentilda/linear/attribution.rb +5 -5
- data/lib/agentilda/linear/import.rb +30 -14
- data/lib/agentilda/linear/issue.rb +6 -3
- data/lib/agentilda/linear/mapping.rb +18 -17
- data/lib/agentilda/linear/push.rb +19 -12
- data/lib/agentilda/linear/survey.rb +3 -2
- data/lib/agentilda/linear/unit.rb +12 -12
- data/lib/agentilda/linear.rb +5 -6
- data/lib/agentilda/mailbox.rb +141 -0
- data/lib/agentilda/markdown.rb +1 -1
- data/lib/agentilda/progress_log.rb +5 -1
- data/lib/agentilda/publisher.rb +6 -3
- data/lib/agentilda/pull_request.rb +0 -186
- data/lib/agentilda/pull_requests.rb +230 -0
- data/lib/agentilda/reporter.rb +9 -3
- data/lib/agentilda/resync.rb +120 -52
- data/lib/agentilda/roster.rb +31 -48
- data/lib/agentilda/runner.rb +72 -389
- data/lib/agentilda/screen/ratatui/bar.rb +57 -0
- data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
- data/lib/agentilda/screen/ratatui.rb +318 -0
- data/lib/agentilda/state_file.rb +174 -0
- data/lib/agentilda/state_machine.rb +60 -32
- data/lib/agentilda/status.rb +120 -37
- data/lib/agentilda/subject.rb +133 -0
- data/lib/agentilda/tally.rb +17 -11
- data/lib/agentilda/transcript.rb +47 -16
- data/lib/agentilda/tree.rb +10 -6
- data/lib/agentilda/ui.rb +204 -190
- data/lib/agentilda/unblocker.rb +25 -11
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda/worktree.rb +14 -18
- data/lib/agentilda.rb +57 -70
- metadata +61 -15
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -261
- data/bin/create-plan-folder +0 -125
- data/lib/dry/cli/banner.rb +0 -293
data/lib/agentilda/ui.rb
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "concurrent/hash"
|
|
4
|
+
require "etc"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "dry/cli/ui"
|
|
7
|
+
require "pastel"
|
|
8
|
+
require "stringio"
|
|
9
|
+
require "tty/screen"
|
|
10
|
+
require "unicode/display_width"
|
|
4
11
|
|
|
5
12
|
module Agentilda
|
|
6
13
|
# Everything the user sees that is not the deliverable itself.
|
|
7
14
|
#
|
|
8
15
|
# Include it and you get `info`, `warn`, `error` and `success` as instance
|
|
9
|
-
# methods, each drawing a
|
|
10
|
-
# documents and tables these commands produce own STDOUT, so every
|
|
11
|
-
# composes in a pipe.
|
|
16
|
+
# methods, each drawing a dry-cli-ui box on **STDERR**. STDERR is deliberate:
|
|
17
|
+
# the documents and tables these commands produce own STDOUT, so every
|
|
18
|
+
# command composes in a pipe.
|
|
12
19
|
#
|
|
13
20
|
# @example
|
|
14
21
|
# class Thing
|
|
@@ -48,29 +55,42 @@ module Agentilda
|
|
|
48
55
|
# the round table under it said FAIL, was the contradiction this closes.
|
|
49
56
|
NO_FAILURE = ->(_result) {}
|
|
50
57
|
|
|
51
|
-
#
|
|
58
|
+
# Longest phrase a spinner line can carry without wrapping into the next.
|
|
59
|
+
SAID_LIMIT = 56
|
|
60
|
+
|
|
61
|
+
# No document to name on a dashboard row.
|
|
62
|
+
NO_FILE = ->(_item) { "" }
|
|
63
|
+
|
|
64
|
+
# One item's line on the screen, and the same news written to the log.
|
|
52
65
|
#
|
|
53
66
|
# These are two readers of one story and used to be told it separately:
|
|
54
67
|
# the spinner got the phrase, the log got a start and a finish, and an
|
|
55
68
|
# animated run wrote nothing about what any agent was actually doing. A
|
|
56
69
|
# {Agentilda::Transcript::Progress} arrives here several times a
|
|
57
|
-
# second; the
|
|
70
|
+
# second; the line is redrawn every time, and the log takes a line only
|
|
58
71
|
# when the phrase itself changes, which is a few dozen times an agent.
|
|
72
|
+
#
|
|
73
|
+
# The drawing belongs to dry-cli-ui. This keeps the parts the text after
|
|
74
|
+
# the agent's name is made of — identity, countdown, meter, phrase — and
|
|
75
|
+
# hands the whole of it to the {Dry::CLI::UI::Line} whenever one changes.
|
|
59
76
|
class Line
|
|
60
77
|
# @param fields [Hash] plan, status and agent, for the log's columns
|
|
61
|
-
# @param
|
|
62
|
-
#
|
|
78
|
+
# @param handle [Dry::CLI::UI::Line, nil] what dry-cli-ui draws this
|
|
79
|
+
# item's line from; nil where nothing is being drawn
|
|
63
80
|
# @param timeout [Integer, nil] seconds until the executor abandons
|
|
64
81
|
# this agent; drawn as a countdown on the line, nil draws nothing
|
|
65
|
-
def initialize(fields: {},
|
|
82
|
+
def initialize(fields: {}, handle: nil, timeout: nil)
|
|
66
83
|
@fields = fields
|
|
67
|
-
@
|
|
68
|
-
@mark = mark
|
|
84
|
+
@handle = handle
|
|
69
85
|
@timeout = timeout
|
|
70
86
|
@started = UI.monotonic
|
|
71
87
|
@phrase = nil
|
|
72
88
|
@pid = nil
|
|
73
89
|
@ticker = nil
|
|
90
|
+
# The meter starts at zero rather than appearing with the first
|
|
91
|
+
# number, which would shift everything after it sideways.
|
|
92
|
+
@parts = { pid: "", timer: "", meter: UI.meter(nil), activity: "" }
|
|
93
|
+
@lock = Mutex.new
|
|
74
94
|
end
|
|
75
95
|
|
|
76
96
|
# @return [Float] seconds this agent has been alive
|
|
@@ -85,7 +105,7 @@ module Agentilda
|
|
|
85
105
|
|
|
86
106
|
# @return [void]
|
|
87
107
|
def start
|
|
88
|
-
|
|
108
|
+
redraw(pid: identity)
|
|
89
109
|
tick
|
|
90
110
|
note("started")
|
|
91
111
|
end
|
|
@@ -107,18 +127,18 @@ module Agentilda
|
|
|
107
127
|
#
|
|
108
128
|
# @return [void]
|
|
109
129
|
def tick
|
|
110
|
-
return unless @
|
|
130
|
+
return unless @handle && @timeout
|
|
111
131
|
|
|
112
|
-
|
|
132
|
+
redraw(timer: UI.countdown(remaining))
|
|
113
133
|
@ticker ||= Thread.new do
|
|
114
134
|
loop do
|
|
115
135
|
sleep(1)
|
|
116
136
|
left = remaining
|
|
117
|
-
|
|
137
|
+
redraw(timer: UI.countdown(left))
|
|
118
138
|
break unless left.positive?
|
|
119
139
|
end
|
|
120
|
-
rescue
|
|
121
|
-
# A dying
|
|
140
|
+
rescue StandardError
|
|
141
|
+
# A dying line must not take the round down with it.
|
|
122
142
|
end
|
|
123
143
|
end
|
|
124
144
|
|
|
@@ -143,18 +163,28 @@ module Agentilda
|
|
|
143
163
|
UI.paint("[#{inner}]", :bright_black)
|
|
144
164
|
end
|
|
145
165
|
|
|
166
|
+
# What the line shows after the agent's name.
|
|
167
|
+
#
|
|
168
|
+
# @return [String] e.g. `[36123, round 01] 14:59 ↑4.9k ↓512: reading spec.md`
|
|
169
|
+
def detail
|
|
170
|
+
parts = @lock.synchronize { @parts.dup }
|
|
171
|
+
[parts[:pid], parts[:timer] + parts[:meter]].map(&:rstrip).reject(&:empty?).join(" ") + parts[:activity]
|
|
172
|
+
end
|
|
173
|
+
|
|
146
174
|
# @return [void]
|
|
147
175
|
def done
|
|
148
176
|
stop_ticker
|
|
149
|
-
@spinner&.success(@mark)
|
|
150
177
|
note("finished after #{alive}")
|
|
151
178
|
end
|
|
152
179
|
|
|
180
|
+
# Ends the line as a failure without raising: the executor reports a
|
|
181
|
+
# timed-out agent by returning, and the line must still say 𝘅.
|
|
182
|
+
#
|
|
153
183
|
# @param reason [String]
|
|
154
184
|
# @return [void]
|
|
155
185
|
def failed(reason)
|
|
156
186
|
stop_ticker
|
|
157
|
-
@
|
|
187
|
+
@handle&.fail(reason)
|
|
158
188
|
note("failed after #{alive}: #{reason}")
|
|
159
189
|
end
|
|
160
190
|
|
|
@@ -163,10 +193,10 @@ module Agentilda
|
|
|
163
193
|
def call(update)
|
|
164
194
|
if update.respond_to?(:pid) && update.pid && update.pid != @pid
|
|
165
195
|
@pid = update.pid
|
|
166
|
-
|
|
196
|
+
redraw(pid: identity)
|
|
167
197
|
note("claude is pid #{@pid}")
|
|
168
198
|
end
|
|
169
|
-
|
|
199
|
+
redraw(meter: UI.meter(update), activity: UI.said(update.activity))
|
|
170
200
|
return if update.activity.nil? || update.activity == @phrase
|
|
171
201
|
|
|
172
202
|
@phrase = update.activity
|
|
@@ -178,6 +208,18 @@ module Agentilda
|
|
|
178
208
|
#
|
|
179
209
|
# @return [Proc]
|
|
180
210
|
def to_proc = method(:call).to_proc
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
# Called from the reader thread and the ticker at once, which is why
|
|
215
|
+
# the parts are replaced under a lock rather than assembled in place.
|
|
216
|
+
#
|
|
217
|
+
# @param parts [Hash{Symbol => String}]
|
|
218
|
+
# @return [void]
|
|
219
|
+
def redraw(**parts)
|
|
220
|
+
@lock.synchronize { @parts.merge!(parts) }
|
|
221
|
+
@handle&.detail = detail
|
|
222
|
+
end
|
|
181
223
|
end
|
|
182
224
|
|
|
183
225
|
class << self
|
|
@@ -262,7 +304,7 @@ module Agentilda
|
|
|
262
304
|
return "" if left.nil?
|
|
263
305
|
|
|
264
306
|
text = fit(format("%d:%02d", left / 60, left % 60), TIMER_WIDTH)
|
|
265
|
-
paint(text,
|
|
307
|
+
paint(text, left <= TIMER_WARNING ? :red : :bright_black)
|
|
266
308
|
end
|
|
267
309
|
|
|
268
310
|
# Token counts run to seven figures, and seven figures on a spinner line
|
|
@@ -282,8 +324,15 @@ module Agentilda
|
|
|
282
324
|
end
|
|
283
325
|
|
|
284
326
|
# @param phrase [String, nil]
|
|
285
|
-
# @return [String] the phrase as a spinner line carries it
|
|
286
|
-
|
|
327
|
+
# @return [String] the phrase as a spinner line carries it, cut to
|
|
328
|
+
# {SAID_LIMIT}
|
|
329
|
+
def said(phrase)
|
|
330
|
+
text = phrase.to_s
|
|
331
|
+
return "" if text.empty?
|
|
332
|
+
|
|
333
|
+
text = "#{text[0, SAID_LIMIT - 1]}…" if text.length > SAID_LIMIT
|
|
334
|
+
paint(": #{text}", :green, :bold)
|
|
335
|
+
end
|
|
287
336
|
|
|
288
337
|
# @return [Boolean] whether STDERR is an interactive terminal
|
|
289
338
|
def tty? = $stderr.tty?
|
|
@@ -298,23 +347,13 @@ module Agentilda
|
|
|
298
347
|
# as a network round trip. The spinner runs until the block returns.
|
|
299
348
|
#
|
|
300
349
|
# @param message [String] what is being waited on
|
|
350
|
+
# @yieldparam activity [Proc] phrase -> void, for news about the work
|
|
301
351
|
# @yieldreturn [Object] whatever the work produces
|
|
302
352
|
# @return [Object] the block's value, untouched
|
|
303
353
|
def spinning(message)
|
|
304
354
|
return yield(logging_activity(message)) unless animate?
|
|
305
355
|
|
|
306
|
-
spinner
|
|
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
|
|
356
|
+
console.spinner(message) { |line| yield(activity_for(line)) }
|
|
318
357
|
end
|
|
319
358
|
|
|
320
359
|
# Determinate work — N items of roughly equal cost. Yields each item and
|
|
@@ -328,20 +367,33 @@ module Agentilda
|
|
|
328
367
|
list = items.to_a
|
|
329
368
|
return list.each(&) unless animate? && list.size >= PROGRESS_THRESHOLD
|
|
330
369
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
list.each do |item|
|
|
337
|
-
yield item
|
|
338
|
-
bar.advance
|
|
370
|
+
console.progress(message, total: list.size) do |bar|
|
|
371
|
+
list.each do |item|
|
|
372
|
+
yield item
|
|
373
|
+
bar.advance
|
|
374
|
+
end
|
|
339
375
|
end
|
|
340
|
-
bar.finish
|
|
341
376
|
list
|
|
342
377
|
end
|
|
343
378
|
|
|
344
|
-
#
|
|
379
|
+
# The dry-cli-ui console every box, spinner and bar here draws through.
|
|
380
|
+
#
|
|
381
|
+
# Both of its streams are STDERR, because dry-cli-ui sends `info` and
|
|
382
|
+
# `success` to its `out`, and STDOUT here belongs to the deliverable.
|
|
383
|
+
# Colour and animation are decided once, by {.color?} and {.animate?},
|
|
384
|
+
# rather than by the gem looking at the stream a second time and
|
|
385
|
+
# possibly disagreeing about --quiet.
|
|
386
|
+
#
|
|
387
|
+
# Built on every call rather than memoized: `$stderr` is swapped out by
|
|
388
|
+
# the specs and by `output(...).to_stderr`, and a console holding on to
|
|
389
|
+
# the stream it was born with would write past every one of them.
|
|
390
|
+
#
|
|
391
|
+
# @return [Dry::CLI::UI::Console]
|
|
392
|
+
def console
|
|
393
|
+
Dry::CLI::UI::Console.new(out: $stderr, err: $stderr, color: color?, animate: animate?, box_width: width)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Run a block over many items at once, one line each.
|
|
345
397
|
#
|
|
346
398
|
# This is the shape for work that is independent and slow: each item gets
|
|
347
399
|
# its own line, its own thread and its own success or failure mark, so a
|
|
@@ -357,6 +409,10 @@ module Agentilda
|
|
|
357
409
|
# NNN.MM` round takes: one plan, one agent, nothing printed until the
|
|
358
410
|
# whole thing finished and it was too late to tell "working" from "hung."
|
|
359
411
|
#
|
|
412
|
+
# Several items are a dry-cli-ui task list, capped at `jobs` at once. A
|
|
413
|
+
# failing item is caught inside its own task, so its line reads 𝘅 and
|
|
414
|
+
# its siblings carry on.
|
|
415
|
+
#
|
|
360
416
|
# @param items [Array]
|
|
361
417
|
# @param message [String] the header line
|
|
362
418
|
# @param jobs [Integer] how many run at once
|
|
@@ -367,55 +423,77 @@ module Agentilda
|
|
|
367
423
|
# @param header [Hash] log columns for the header line itself, so the
|
|
368
424
|
# line announcing a round carries the same round number as the agent
|
|
369
425
|
# lines under it rather than a blank cell
|
|
426
|
+
# @param file [Proc] item -> the document the dashboard row names
|
|
427
|
+
# @param root [String] the directory the dashboard's bottom bar names
|
|
370
428
|
# @yieldparam item [Object]
|
|
429
|
+
# @yieldparam line [Line] a {Dashboard::Tracker} on a terminal, whose
|
|
430
|
+
# `handle` a caller may pass to {Executor#call}
|
|
371
431
|
# @return [Array] one result per item, in input order
|
|
372
432
|
def concurrently(items, message, jobs:, label: :to_s.to_proc, fields: NO_FIELDS,
|
|
373
|
-
failure: NO_FAILURE, header: {}, timeout: NO_TIMEOUT, &
|
|
433
|
+
failure: NO_FAILURE, header: {}, timeout: NO_TIMEOUT, file: NO_FILE, root: Dir.pwd, &)
|
|
374
434
|
list = items.to_a
|
|
375
435
|
return [] if list.empty?
|
|
376
436
|
|
|
377
437
|
log(message, **header)
|
|
438
|
+
return on_dashboard(list, jobs:, label:, fields:, failure:, timeout:, file:, root:, &) if animate?
|
|
378
439
|
|
|
379
440
|
if jobs <= 1 || list.size <= 1
|
|
380
441
|
report_line(message) unless animate?
|
|
381
|
-
return list.map { |item| once(item, label, fields, failure:, timeout:, &
|
|
442
|
+
return list.map { |item| once(item, label, fields, failure:, timeout:, &) }
|
|
382
443
|
end
|
|
383
444
|
|
|
384
|
-
return threaded(list, jobs, message, label:, fields:, failure:, &block) unless animate?
|
|
385
|
-
|
|
386
445
|
results = Concurrent::Hash.new
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
|
446
|
+
progress.tasks(message, concurrent: jobs) do |tasks|
|
|
447
|
+
list.each_with_index do |item, index|
|
|
448
|
+
tasks.task(label.call(item)) do |handle|
|
|
449
|
+
line = Line.new(fields: fields.call(item), handle:, timeout: timeout.call(item))
|
|
450
|
+
results[index] = attempt(item, line, failure, &)
|
|
451
|
+
rescue StandardError => e
|
|
452
|
+
results[index] = e
|
|
403
453
|
end
|
|
404
|
-
rescue => e
|
|
405
|
-
results[index] = e
|
|
406
|
-
line&.failed(e.message.lines.first.to_s.strip)
|
|
407
454
|
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
455
|
end
|
|
414
|
-
|
|
415
|
-
spinners.auto_spin
|
|
416
456
|
list.each_index.map { |i| results[i] }
|
|
417
457
|
end
|
|
418
458
|
|
|
459
|
+
# Agent work a person waits on, drawn on the ratatui dashboard: one
|
|
460
|
+
# row per item, at most `jobs` running at once. As with the task list,
|
|
461
|
+
# one item's failure is its own result when several run; a lone item's
|
|
462
|
+
# exception propagates, as {.once} lets it.
|
|
463
|
+
#
|
|
464
|
+
# @return [Array] one result per item, in input order
|
|
465
|
+
def on_dashboard(list, jobs:, label:, fields:, failure:, timeout:, file:, root:, &)
|
|
466
|
+
Dashboard.open(root:) do |dashboard|
|
|
467
|
+
# Tracked when it starts, not when it is queued: a row's clock is
|
|
468
|
+
# the agent's clock, and a queued plan has no agent yet.
|
|
469
|
+
run = lambda do |item|
|
|
470
|
+
tracker = dashboard.track(key: label.call(item),
|
|
471
|
+
fields: fields.call(item),
|
|
472
|
+
file: file.call(item),
|
|
473
|
+
timeout: timeout.call(item))
|
|
474
|
+
attempt(item, tracker, failure, &)
|
|
475
|
+
end
|
|
476
|
+
next list.map(&run) if jobs <= 1 || list.size <= 1
|
|
477
|
+
|
|
478
|
+
results = Concurrent::Hash.new
|
|
479
|
+
queue = Queue.new
|
|
480
|
+
list.each_index { |i| queue << i }
|
|
481
|
+
queue.close
|
|
482
|
+
Array.new([jobs, list.size].min) {
|
|
483
|
+
Thread.new do
|
|
484
|
+
while (i = queue.pop)
|
|
485
|
+
results[i] = begin
|
|
486
|
+
run.call(list[i])
|
|
487
|
+
rescue StandardError => e
|
|
488
|
+
e
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
}.each(&:join)
|
|
493
|
+
list.each_index.map { |i| results[i] }
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
419
497
|
# The same news, with no spinner to put it on. A piped or CI run still
|
|
420
498
|
# wants it, in the log where the rest of that run's progress goes.
|
|
421
499
|
#
|
|
@@ -423,19 +501,12 @@ module Agentilda
|
|
|
423
501
|
# @return [Proc] phrase -> void
|
|
424
502
|
def logging_activity(text) = ->(phrase) { log("#{text}: #{phrase}") }
|
|
425
503
|
|
|
426
|
-
# A callable that writes what
|
|
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.
|
|
504
|
+
# A callable that writes what the work is doing after its spinner's label.
|
|
432
505
|
#
|
|
433
|
-
# @param
|
|
506
|
+
# @param line [Dry::CLI::UI::Line]
|
|
434
507
|
# @return [Proc] phrase -> void
|
|
435
|
-
def activity_for(
|
|
436
|
-
|
|
437
|
-
spinner.update(activity: phrase.to_s.empty? ? "" : paint(": #{phrase}", :green, :bold))
|
|
438
|
-
}
|
|
508
|
+
def activity_for(line)
|
|
509
|
+
->(phrase) { line.detail = phrase.to_s.empty? ? "" : paint("— #{phrase}", :green, :bold) }
|
|
439
510
|
end
|
|
440
511
|
|
|
441
512
|
# One item, no concurrency to speak of: a serial round (`--isolation
|
|
@@ -446,77 +517,44 @@ module Agentilda
|
|
|
446
517
|
# @param label [Proc]
|
|
447
518
|
# @yieldparam item [Object]
|
|
448
519
|
# @return [Object]
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
520
|
+
# @raise [StandardError] whatever the block raised, once its line says so
|
|
521
|
+
def once(item, label, fields = NO_FIELDS, failure: NO_FAILURE, timeout: NO_TIMEOUT, &)
|
|
522
|
+
progress.spinner(label.call(item)) do |handle|
|
|
523
|
+
attempt(item, Line.new(fields: fields.call(item), handle:, timeout: timeout.call(item)), failure, &)
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Runs one item and says how it went, on its line and in the log.
|
|
528
|
+
#
|
|
529
|
+
# @param item [Object]
|
|
530
|
+
# @param line [Line]
|
|
531
|
+
# @param failure [Proc]
|
|
532
|
+
# @return [Object] the block's value
|
|
533
|
+
def attempt(item, line, failure)
|
|
453
534
|
line.start
|
|
454
535
|
begin
|
|
455
|
-
result =
|
|
456
|
-
rescue => e
|
|
457
|
-
|
|
458
|
-
line.failed(reason)
|
|
459
|
-
report_line("#{text}: #{reason}", bullet: "✗") unless animate?
|
|
536
|
+
result = yield(item, line)
|
|
537
|
+
rescue StandardError => e
|
|
538
|
+
line.failed(e.message.lines.first.to_s.strip)
|
|
460
539
|
raise
|
|
461
540
|
end
|
|
462
541
|
if (reason = failure.call(result))
|
|
463
542
|
line.failed(reason)
|
|
464
|
-
report_line("#{text}: #{reason}", bullet: "✗") unless animate?
|
|
465
543
|
else
|
|
466
544
|
line.done
|
|
467
|
-
report_line("#{text} (#{line.alive})", bullet: "✓") unless animate?
|
|
468
545
|
end
|
|
469
546
|
result
|
|
470
547
|
end
|
|
471
548
|
|
|
472
|
-
# The
|
|
473
|
-
#
|
|
549
|
+
# The console per-item lines go through: {.console}, or one that
|
|
550
|
+
# writes nowhere under --quiet, so a quiet run really is quiet while
|
|
551
|
+
# the work, the log and the results stay exactly the same.
|
|
474
552
|
#
|
|
475
|
-
# @
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
553
|
+
# @return [Dry::CLI::UI::Console]
|
|
554
|
+
def progress
|
|
555
|
+
return console unless quiet
|
|
484
556
|
|
|
485
|
-
|
|
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] }
|
|
557
|
+
Dry::CLI::UI::Console.new(out: StringIO.new, err: StringIO.new, color: false, animate: false)
|
|
520
558
|
end
|
|
521
559
|
|
|
522
560
|
# @return [Float] a monotonic clock reading, immune to wall-clock changes
|
|
@@ -561,7 +599,7 @@ module Agentilda
|
|
|
561
599
|
|
|
562
600
|
# @param text [String]
|
|
563
601
|
# @return [Integer] how many terminal cells the text occupies
|
|
564
|
-
def display_width(text) = Unicode::DisplayWidth.of(text.to_s)
|
|
602
|
+
def display_width(text) = ::Unicode::DisplayWidth.of(text.to_s)
|
|
565
603
|
|
|
566
604
|
# Pad or truncate to an exact number of terminal *cells*.
|
|
567
605
|
#
|
|
@@ -581,26 +619,22 @@ module Agentilda
|
|
|
581
619
|
text + (" " * (width - display_width(text)))
|
|
582
620
|
end
|
|
583
621
|
|
|
584
|
-
#
|
|
585
|
-
#
|
|
622
|
+
# Every box is drawn by dry-cli-ui, on STDERR, through {.console}.
|
|
623
|
+
#
|
|
624
|
+
# Not through `Kernel.warn`, which is a **no-op** when `$VERBOSE` is
|
|
625
|
+
# nil, which is what `-W0` sets — and `RUBYOPT=-W0` is common in CI
|
|
626
|
+
# images and agent harnesses. Routed through `Kernel.warn`, every box
|
|
627
|
+
# this tool draws silently disappears in exactly the environments where
|
|
628
|
+
# a failure most needs explaining. The console writes to its stream.
|
|
586
629
|
#
|
|
587
|
-
#
|
|
588
|
-
#
|
|
589
|
-
#
|
|
590
|
-
# every box this tool draws silently disappears in exactly the
|
|
591
|
-
# environments where a failure most needs explaining.
|
|
630
|
+
# dry-cli-ui wraps before it frames, so a long line no longer costs the
|
|
631
|
+
# box its last row, which is where the instruction lives. One run
|
|
632
|
+
# reported four of its ten failures and cut the fifth mid-sentence.
|
|
592
633
|
#
|
|
593
634
|
# @param kind [Symbol] :info, :warn, :error or :success
|
|
594
635
|
# @param message [String]
|
|
595
636
|
# @return [void]
|
|
596
|
-
|
|
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
|
|
637
|
+
def box(kind, message) = console.public_send(kind, message.to_s)
|
|
604
638
|
|
|
605
639
|
# A framed panel centered on the screen, for the keyboard help. Unlike
|
|
606
640
|
# {.box} it positions itself absolutely, so it overlays whatever the
|
|
@@ -610,35 +644,17 @@ module Agentilda
|
|
|
610
644
|
# @param title [String]
|
|
611
645
|
# @param text [String]
|
|
612
646
|
# @return [void]
|
|
613
|
-
|
|
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
|
|
647
|
+
def popup(title, text) = console.popup(text.to_s, title:)
|
|
627
648
|
|
|
628
|
-
#
|
|
629
|
-
#
|
|
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.
|
|
649
|
+
# A framed table, returned rather than printed: a table is a
|
|
650
|
+
# deliverable, so it belongs on STDOUT and the caller picks the stream.
|
|
638
651
|
#
|
|
639
|
-
# @param
|
|
640
|
-
# @
|
|
641
|
-
|
|
652
|
+
# @param rows [Array<Array<#to_s>>]
|
|
653
|
+
# @param header [Array<#to_s>, nil]
|
|
654
|
+
# @return [String] the table ending in a newline, or "" when there are no rows
|
|
655
|
+
def table(rows, header: nil)
|
|
656
|
+
Dry::CLI::UI::Widgets::Table.new(Dry::CLI::UI::Terminal.new($stdout, color: color?)).render(rows, header:)
|
|
657
|
+
end
|
|
642
658
|
|
|
643
659
|
# A single unadorned line, for per-item progress that does not deserve
|
|
644
660
|
# a box of its own.
|
|
@@ -646,10 +662,8 @@ module Agentilda
|
|
|
646
662
|
# @param message [String]
|
|
647
663
|
# @param bullet [String]
|
|
648
664
|
# @return [void]
|
|
649
|
-
#
|
|
665
|
+
# rubocop:disable-next Style/StderrPuts -- see {.box}: `warn` is a no-op under -W0.
|
|
650
666
|
def line(message, bullet: "·") = $stderr.puts(" #{paint(bullet, :bright_black)} #{message}")
|
|
651
|
-
|
|
652
|
-
# standard:enable Style/StderrPuts
|
|
653
667
|
end
|
|
654
668
|
|
|
655
669
|
# @param message [String]
|