agentilda 1.0.3 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/agentilda.gemspec +10 -6
  3. data/agents/hansolo-reviewer.md +42 -13
  4. data/agents/lando-broker.md +40 -46
  5. data/agents/leah-researcher.md +36 -58
  6. data/agents/luke-backend.md +49 -61
  7. data/agents/palpatine-planner.md +39 -21
  8. data/agents/rey-frontend.md +44 -86
  9. data/agents/yoda-writer.md +38 -36
  10. data/bin/setup +26 -0
  11. data/exe/agentilda +13 -92
  12. data/exe/tilda-rs +0 -0
  13. data/lib/agentilda/adoption.rb +7 -2
  14. data/lib/agentilda/agent.rb +40 -99
  15. data/lib/agentilda/agents.rb +122 -0
  16. data/lib/agentilda/board.rb +91 -0
  17. data/lib/agentilda/brief.rb +54 -5
  18. data/lib/agentilda/child.rb +92 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
  20. data/lib/agentilda/cli/base.rb +46 -6
  21. data/lib/agentilda/cli/create/create.rb +46 -19
  22. data/lib/agentilda/cli/docs/docs.rb +6 -2
  23. data/lib/agentilda/cli/index/index.rb +6 -4
  24. data/lib/agentilda/cli/linear/linear.rb +3 -2
  25. data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
  26. data/lib/agentilda/cli/mail/mail.rb +31 -0
  27. data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
  28. data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
  31. data/lib/agentilda/cli/run/run.rb +207 -99
  32. data/lib/agentilda/cli/unblock/unblock.rb +24 -13
  33. data/lib/agentilda/cli/worktree/worktree.rb +85 -0
  34. data/lib/agentilda/cli.rb +43 -0
  35. data/lib/agentilda/clock.rb +114 -0
  36. data/lib/agentilda/console.rb +157 -0
  37. data/lib/agentilda/control.rb +63 -0
  38. data/lib/agentilda/creator.rb +7 -2
  39. data/lib/agentilda/dashboard.rb +219 -0
  40. data/lib/agentilda/dev_work.rb +2 -0
  41. data/lib/agentilda/diagram.rb +12 -7
  42. data/lib/agentilda/dispatcher.rb +646 -0
  43. data/lib/agentilda/documentation.rb +14 -6
  44. data/lib/agentilda/executor.rb +362 -204
  45. data/lib/agentilda/feature.rb +14 -135
  46. data/lib/agentilda/frontmatter.rb +4 -2
  47. data/lib/agentilda/github.rb +19 -11
  48. data/lib/agentilda/index.rb +11 -11
  49. data/lib/agentilda/keyboard.rb +76 -8
  50. data/lib/agentilda/launcher.rb +69 -0
  51. data/lib/agentilda/ledger.rb +233 -0
  52. data/lib/agentilda/linear/api.rb +14 -10
  53. data/lib/agentilda/linear/attribution.rb +5 -5
  54. data/lib/agentilda/linear/import.rb +30 -14
  55. data/lib/agentilda/linear/issue.rb +6 -3
  56. data/lib/agentilda/linear/mapping.rb +18 -17
  57. data/lib/agentilda/linear/push.rb +19 -12
  58. data/lib/agentilda/linear/survey.rb +3 -2
  59. data/lib/agentilda/linear/unit.rb +12 -12
  60. data/lib/agentilda/linear.rb +5 -6
  61. data/lib/agentilda/mailbox.rb +141 -0
  62. data/lib/agentilda/markdown.rb +1 -1
  63. data/lib/agentilda/progress_log.rb +5 -1
  64. data/lib/agentilda/publisher.rb +6 -3
  65. data/lib/agentilda/pull_request.rb +0 -186
  66. data/lib/agentilda/pull_requests.rb +230 -0
  67. data/lib/agentilda/reporter.rb +9 -3
  68. data/lib/agentilda/resync.rb +120 -52
  69. data/lib/agentilda/roster.rb +31 -48
  70. data/lib/agentilda/runner.rb +72 -389
  71. data/lib/agentilda/screen/ratatui/bar.rb +57 -0
  72. data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
  73. data/lib/agentilda/screen/ratatui.rb +318 -0
  74. data/lib/agentilda/state_file.rb +174 -0
  75. data/lib/agentilda/state_machine.rb +60 -32
  76. data/lib/agentilda/status.rb +120 -37
  77. data/lib/agentilda/subject.rb +133 -0
  78. data/lib/agentilda/tally.rb +17 -11
  79. data/lib/agentilda/transcript.rb +47 -16
  80. data/lib/agentilda/tree.rb +10 -6
  81. data/lib/agentilda/ui.rb +204 -190
  82. data/lib/agentilda/unblocker.rb +25 -11
  83. data/lib/agentilda/version.rb +1 -1
  84. data/lib/agentilda/worktree.rb +14 -18
  85. data/lib/agentilda.rb +57 -70
  86. metadata +65 -13
  87. data/Gemfile +0 -26
  88. data/Gemfile.lock +0 -261
  89. data/bin/create-plan-folder +0 -125
  90. 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 "tty/spinner/multi"
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 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.
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
- # One item's spinner line, and the same news written to the log.
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 spinner is redrawn every time, and the log takes a line only
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 spinner [TTY::Spinner, nil] nil where nothing is being drawn
62
- # @param mark [String] what the spinner says once the work succeeds
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: {}, spinner: nil, mark: "", timeout: nil)
82
+ def initialize(fields: {}, handle: nil, timeout: nil)
66
83
  @fields = fields
67
- @spinner = spinner
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
- @spinner&.update(pid: identity)
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 @spinner && @timeout
130
+ return unless @handle && @timeout
111
131
 
112
- @spinner.update(timer: UI.countdown(remaining))
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
- @spinner.update(timer: UI.countdown(left))
137
+ redraw(timer: UI.countdown(left))
118
138
  break unless left.positive?
119
139
  end
120
- rescue
121
- # A dying spinner must not take the round down with it.
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
- @spinner&.error(UI.paint(reason, :red))
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
- @spinner&.update(pid: identity)
196
+ redraw(pid: identity)
167
197
  note("claude is pid #{@pid}")
168
198
  end
169
- @spinner&.update(meter: UI.meter(update), activity: UI.said(update.activity))
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, (left <= TIMER_WARNING) ? :red : :bright_black)
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
- def said(phrase) = phrase.to_s.empty? ? "" : paint(": #{phrase}", :green, :bold)
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 = 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
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
- 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
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
- # Run a block over many items at once, one spinner each.
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, &block)
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:, &block) }
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
- 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
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 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.
504
+ # A callable that writes what the work is doing after its spinner's label.
432
505
  #
433
- # @param spinner [TTY::Spinner]
506
+ # @param line [Dry::CLI::UI::Line]
434
507
  # @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
- }
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
- 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?))
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 = 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?
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 spinner a lone agent gets. Registered nowhere, because there is no
473
- # second line for it to line up with.
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
- # @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
553
+ # @return [Dry::CLI::UI::Console]
554
+ def progress
555
+ return console unless quiet
484
556
 
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] }
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
- # Everything the user sees goes through here, so it writes to `$stderr`
585
- # directly rather than through `Kernel.warn`.
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
- # 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.
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
- # 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
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
- # 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
647
+ def popup(title, text) = console.popup(text.to_s, title:)
627
648
 
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.
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 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
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
- # standard:disable Style/StderrPuts -- see {.box}: `warn` is a no-op under -W0.
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]