riffer-rig 0.5.0 → 0.6.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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +43 -6
  4. data/lib/riffer/rig/cli.rb +46 -41
  5. data/lib/riffer/rig/credentials/resolution.rb +16 -0
  6. data/lib/riffer/rig/credentials.rb +151 -60
  7. data/lib/riffer/rig/env.rb +29 -0
  8. data/lib/riffer/rig/events/command_output.rb +27 -0
  9. data/lib/riffer/rig/events/event.rb +30 -0
  10. data/lib/riffer/rig/events/notify.rb +27 -0
  11. data/lib/riffer/rig/events/session_end.rb +24 -0
  12. data/lib/riffer/rig/events/session_start.rb +27 -0
  13. data/lib/riffer/rig/events/skill_activated.rb +24 -0
  14. data/lib/riffer/rig/events/turn_end.rb +32 -0
  15. data/lib/riffer/rig/events.rb +4 -0
  16. data/lib/riffer/rig/extension.rb +0 -4
  17. data/lib/riffer/rig/hosts/base.rb +36 -0
  18. data/lib/riffer/rig/hosts/mirror.rb +60 -0
  19. data/lib/riffer/rig/hosts/null.rb +36 -0
  20. data/lib/riffer/rig/provider_setup/field.rb +24 -0
  21. data/lib/riffer/rig/provider_setup.rb +84 -0
  22. data/lib/riffer/rig/registrar.rb +14 -2
  23. data/lib/riffer/rig/repl.rb +7 -8
  24. data/lib/riffer/rig/runtime/cancel_flag.rb +27 -0
  25. data/lib/riffer/rig/runtime.rb +104 -41
  26. data/lib/riffer/rig/settings/document.rb +6 -1
  27. data/lib/riffer/rig/settings/pricing.rb +0 -1
  28. data/lib/riffer/rig/settings.rb +47 -50
  29. data/lib/riffer/rig/token_tally.rb +0 -20
  30. data/lib/riffer/rig/tools/bash.rb +32 -11
  31. data/lib/riffer/rig/ui/animator.rb +2 -9
  32. data/lib/riffer/rig/ui/cursor.rb +0 -2
  33. data/lib/riffer/rig/ui/renderer.rb +9 -24
  34. data/lib/riffer/rig/ui/smoother.rb +11 -19
  35. data/lib/riffer/rig/ui/theme.rb +3 -5
  36. data/lib/riffer/rig/version.rb +1 -1
  37. data/lib/riffer/rig.rb +2 -7
  38. data/sig/generated/riffer/rig/cli.rbs +20 -12
  39. data/sig/generated/riffer/rig/credentials/resolution.rbs +13 -0
  40. data/sig/generated/riffer/rig/credentials.rbs +70 -41
  41. data/sig/generated/riffer/rig/env.rbs +20 -0
  42. data/sig/generated/riffer/rig/events/command_output.rbs +19 -0
  43. data/sig/generated/riffer/rig/events/event.rbs +20 -0
  44. data/sig/generated/riffer/rig/events/notify.rbs +19 -0
  45. data/sig/generated/riffer/rig/events/session_end.rbs +16 -0
  46. data/sig/generated/riffer/rig/events/session_start.rbs +19 -0
  47. data/sig/generated/riffer/rig/events/skill_activated.rbs +16 -0
  48. data/sig/generated/riffer/rig/events/turn_end.rbs +22 -0
  49. data/sig/generated/riffer/rig/events.rbs +4 -0
  50. data/sig/generated/riffer/rig/extension.rbs +0 -4
  51. data/sig/generated/riffer/rig/hosts/base.rbs +26 -0
  52. data/sig/generated/riffer/rig/hosts/mirror.rbs +41 -0
  53. data/sig/generated/riffer/rig/hosts/null.rbs +26 -0
  54. data/sig/generated/riffer/rig/provider_setup/field.rbs +22 -0
  55. data/sig/generated/riffer/rig/provider_setup.rbs +30 -0
  56. data/sig/generated/riffer/rig/registrar.rbs +10 -2
  57. data/sig/generated/riffer/rig/repl.rbs +0 -8
  58. data/sig/generated/riffer/rig/runtime/cancel_flag.rbs +19 -0
  59. data/sig/generated/riffer/rig/runtime.rbs +58 -39
  60. data/sig/generated/riffer/rig/settings/document.rbs +5 -1
  61. data/sig/generated/riffer/rig/settings/pricing.rbs +0 -1
  62. data/sig/generated/riffer/rig/settings.rbs +25 -46
  63. data/sig/generated/riffer/rig/token_tally.rbs +0 -19
  64. data/sig/generated/riffer/rig/tools/bash.rbs +13 -1
  65. data/sig/generated/riffer/rig/ui/animator.rbs +0 -8
  66. data/sig/generated/riffer/rig/ui/cursor.rbs +0 -2
  67. data/sig/generated/riffer/rig/ui/renderer.rbs +0 -24
  68. data/sig/generated/riffer/rig/ui/smoother.rbs +5 -18
  69. data/sig/generated/riffer/rig/ui/theme.rbs +2 -4
  70. data/sig/generated/riffer/rig.rbs +0 -7
  71. data/sig/manual/riffer/rig/cli.rbs +2 -4
  72. data/sig/manual/riffer/rig/credentials.rbs +2 -4
  73. data/sig/manual/riffer/rig/hosts.rbs +5 -0
  74. data/sig/manual/riffer/rig/settings.rbs +2 -4
  75. data/sig/manual/riffer/rig/ui/banner.rbs +2 -4
  76. metadata +38 -13
  77. data/lib/riffer/rig/host.rb +0 -52
  78. data/sig/generated/riffer/rig/host.rbs +0 -42
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # USD per million tokens, parsed from a model's entry in the settings file.
4
3
  class Riffer::Rig::Settings::Pricing
5
4
  # @dynamic input, output, cache_write, cache_read
6
5
  attr_reader :input, :output, :cache_write, :cache_read #: Float
@@ -3,36 +3,6 @@
3
3
  require 'json'
4
4
  require 'fileutils'
5
5
 
6
- # Reads and writes the riffer-rig user settings file at
7
- # <tt>~/.riffer/settings.json</tt>.
8
- #
9
- # Example file:
10
- #
11
- # {
12
- # "model": "anthropic/claude-sonnet-4-6",
13
- # "reasoning": "low",
14
- # "models": {
15
- # "anthropic/claude-sonnet-4-6": {
16
- # "input": 3.0,
17
- # "output": 15.0,
18
- # "cache_write": 3.75,
19
- # "cache_read": 0.3
20
- # }
21
- # }
22
- # }
23
- #
24
- # All keys are optional. Missing pricing means cost display is suppressed.
25
- # <tt>"reasoning"</tt> is translated to the appropriate provider-specific
26
- # parameter on each API call; accepted values depend on the provider:
27
- #
28
- # - Anthropic: <tt>"low"</tt>, <tt>"medium"</tt>, <tt>"high"</tt>,
29
- # <tt>"xhigh"</tt>, <tt>"max"</tt>
30
- # - OpenAI / OpenRouter: <tt>"low"</tt>, <tt>"medium"</tt>, <tt>"high"</tt>,
31
- # <tt>"xhigh"</tt>
32
- #
33
- # Omitting the key (or supplying an unrecognised value) leaves the model's
34
- # default reasoning behaviour unchanged.
35
- #
36
6
  module Riffer::Rig::Settings
37
7
  extend self
38
8
 
@@ -46,17 +16,12 @@ module Riffer::Rig::Settings
46
16
  'openrouter' => %w[low medium high xhigh].freeze
47
17
  }.freeze #: Hash[String, Array[String]]
48
18
 
49
- # Returns the configured model string, or +DEFAULT_MODEL+ if not set.
50
- #
51
19
  # @rbs path: String
52
20
  # @rbs return: String
53
21
  def model(path: PATH)
54
22
  read(path).model || DEFAULT_MODEL
55
23
  end
56
24
 
57
- # Returns model options for the configured model and reasoning level, ready
58
- # to pass directly to the Riffer agent's +model_options+.
59
- #
60
25
  # @rbs path: String
61
26
  # @rbs return: Hash[Symbol, untyped]
62
27
  def model_options(path: PATH)
@@ -64,18 +29,12 @@ module Riffer::Rig::Settings
64
29
  base_options(provider).merge(reasoning_options(reasoning_for(path:, provider:), provider))
65
30
  end
66
31
 
67
- # Returns the provider prefix for +model_string+, e.g. <tt>"anthropic"</tt>
68
- # for <tt>"anthropic/claude-sonnet-4-6"</tt>. Returns +nil+ if the model
69
- # string contains no slash.
70
- #
71
32
  # @rbs model_string: String
72
33
  # @rbs return: String?
73
34
  def provider_for(model_string)
74
35
  model_string.split('/', 2).first if model_string.include?('/')
75
36
  end
76
37
 
77
- # Returns the pricing for +model+, or +nil+ if not configured.
78
- #
79
38
  # @rbs model: String
80
39
  # @rbs path: String
81
40
  # @rbs return: Pricing?
@@ -83,8 +42,46 @@ module Riffer::Rig::Settings
83
42
  read(path).models[model]
84
43
  end
85
44
 
45
+ # @rbs identifier: String
46
+ # @rbs path: String
47
+ # @rbs return: Hash[String, String]
48
+ def provider_fields(identifier, path: PATH)
49
+ read(path).providers[identifier] || {}
50
+ end
51
+
52
+ # @rbs identifier: String
53
+ # @rbs fields: Hash[String, String]
54
+ # @rbs path: String
55
+ # @rbs return: void
56
+ def store_provider(identifier, fields, path: PATH)
57
+ update_providers(path) do |providers|
58
+ providers.merge(identifier => fields) { |_identifier, stored, given| stored.merge(given) }
59
+ end
60
+ end
61
+
62
+ # @rbs identifier: String
63
+ # @rbs path: String
64
+ # @rbs return: void
65
+ def remove_provider(identifier, path: PATH)
66
+ update_providers(path) { |providers| providers.except(identifier) }
67
+ end
68
+
86
69
  private
87
70
 
71
+ # @rbs path: String
72
+ # @rbs &: (Hash[String, Hash[String, String]]) -> Hash[String, Hash[String, String]]
73
+ # @rbs return: void
74
+ def update_providers(path)
75
+ source = read_source(path)
76
+ current = Document.new(source).providers
77
+ providers = yield current
78
+ return if providers == current
79
+
80
+ FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
81
+ document = providers.empty? ? source.except('providers') : source.merge('providers' => providers)
82
+ File.write(path, JSON.pretty_generate(document))
83
+ end
84
+
88
85
  # @rbs provider: String?
89
86
  # @rbs return: Hash[Symbol, untyped]
90
87
  def base_options(provider)
@@ -102,9 +99,6 @@ module Riffer::Rig::Settings
102
99
  valid_levels.include?(level) ? level : nil
103
100
  end
104
101
 
105
- # Maps a reasoning level to the provider-specific model option hash expected
106
- # by Riffer. Returns an empty hash when +level+ is +nil+.
107
- #
108
102
  # @rbs level: String?
109
103
  # @rbs provider: String?
110
104
  # @rbs return: Hash[Symbol, untyped]
@@ -121,16 +115,19 @@ module Riffer::Rig::Settings
121
115
  end
122
116
  end
123
117
 
124
- # The parsed settings file, or an empty document when the file is absent or
125
- # malformed.
126
- #
127
118
  # @rbs path: String
128
119
  # @rbs return: Document
129
120
  def read(path)
130
- return Document.new({}) unless File.file?(path)
121
+ Document.new(read_source(path))
122
+ end
123
+
124
+ # @rbs path: String
125
+ # @rbs return: Hash[String, untyped]
126
+ def read_source(path)
127
+ return {} unless File.file?(path)
131
128
 
132
- Document.new(JSON.parse(File.read(path)))
129
+ JSON.parse(File.read(path))
133
130
  rescue JSON::ParserError
134
- Document.new({})
131
+ {}
135
132
  end
136
133
  end
@@ -1,17 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Accumulates token usage across multiple turns in a session and computes an
4
- # estimated cost based on a pricing object sourced from
5
- # +Riffer::Rig::Settings+.
6
- #
7
- # Instantiate once per REPL session, call +add+ after each turn, then read
8
- # +total_tokens+ and +estimated_cost+ for display.
9
- #
10
- # pricing = Riffer::Rig::Settings.pricing_for('anthropic/claude-sonnet-4-6')
11
- # tally = Riffer::Rig::TokenTally.new(pricing: pricing)
12
- # tally.add(usage)
13
- # tally.estimated_cost # => 0.000042 (USD), or nil if pricing is nil
14
- #
15
3
  class Riffer::Rig::TokenTally
16
4
  # @rbs @input_tokens: Integer
17
5
  # @rbs @output_tokens: Integer
@@ -32,8 +20,6 @@ class Riffer::Rig::TokenTally
32
20
  @cache_read_tokens = 0
33
21
  end
34
22
 
35
- # Accumulates token counts from a +Riffer::Providers::TokenUsage+ object.
36
- #
37
23
  # @rbs usage: Riffer::Providers::TokenUsage
38
24
  # @rbs return: void
39
25
  def add(usage)
@@ -43,22 +29,16 @@ class Riffer::Rig::TokenTally
43
29
  @cache_read_tokens += usage.cache_read_tokens || 0
44
30
  end
45
31
 
46
- # Returns the total token count across all categories.
47
- #
48
32
  # @rbs return: Integer
49
33
  def total_tokens
50
34
  @input_tokens + @output_tokens + @cache_write_tokens + @cache_read_tokens
51
35
  end
52
36
 
53
- # Returns +true+ if any tokens have been counted.
54
- #
55
37
  # @rbs return: bool
56
38
  def any?
57
39
  total_tokens.positive?
58
40
  end
59
41
 
60
- # Returns the estimated cost in USD, or +nil+ if no pricing was provided.
61
- #
62
42
  # @rbs return: Float?
63
43
  def estimated_cost
64
44
  pricing = @pricing
@@ -13,6 +13,8 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
13
13
 
14
14
  MAX_OUTPUT_BYTES = 30_000 #: Integer
15
15
 
16
+ POLL_SECONDS = 0.1 #: Float
17
+
16
18
  params do
17
19
  required :command, String, description: 'The shell command to run'
18
20
  optional :timeout_ms,
@@ -26,7 +28,8 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
26
28
  # @rbs timeout_ms: Integer
27
29
  # @rbs return: Riffer::Tools::Response
28
30
  def call(context:, command:, timeout_ms: DEFAULT_TIMEOUT_MS)
29
- output, status = run(command, timeout_ms / 1000.0)
31
+ cancel_flag = context&.[](:cancel_flag) #: Riffer::Rig::Runtime::CancelFlag?
32
+ output, status = run(command, timeout_ms / 1000.0, cancel_flag)
30
33
  output = truncate(output.rstrip)
31
34
 
32
35
  return error("Command exited with status #{status}\n#{output}", type: :command_failed) unless status.zero?
@@ -38,23 +41,41 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
38
41
 
39
42
  # @rbs command: String
40
43
  # @rbs timeout_seconds: Float
44
+ # @rbs cancel_flag: Riffer::Rig::Runtime::CancelFlag?
41
45
  # @rbs return: [String, Integer]
42
- def run(command, timeout_seconds)
46
+ def run(command, timeout_seconds, cancel_flag)
43
47
  stdin, stdout_and_stderr, wait_thread = Open3.popen2e(command, chdir: Dir.pwd, pgroup: true)
44
48
  stdin.close
45
49
 
46
- joined = wait_thread.join(timeout_seconds) #: Thread?
50
+ ending = await(wait_thread, monotonic_now + timeout_seconds, cancel_flag)
51
+ kill_group(wait_thread.pid) unless ending == :exited
52
+ output = stdout_and_stderr.read
53
+ stdout_and_stderr.close
47
54
 
48
- unless joined
49
- kill_group(wait_thread.pid)
50
- output = stdout_and_stderr.read
51
- stdout_and_stderr.close
52
- return ["#{output}\n[timed out after #{timeout_seconds.round}s]", 124]
55
+ case ending
56
+ when :timed_out then ["#{output}\n[timed out after #{timeout_seconds.round}s]", 124]
57
+ when :cancelled then ["#{output}\n[cancelled]", 130]
58
+ else [output, wait_thread.value.exitstatus || 1]
53
59
  end
60
+ end
54
61
 
55
- output = stdout_and_stderr.read
56
- stdout_and_stderr.close
57
- [output, wait_thread.value.exitstatus || 1]
62
+ # @rbs wait_thread: Process::Waiter
63
+ # @rbs deadline: Float
64
+ # @rbs cancel_flag: Riffer::Rig::Runtime::CancelFlag?
65
+ # @rbs return: Symbol
66
+ def await(wait_thread, deadline, cancel_flag)
67
+ # Upstream candidate: a cancel token on riffer's run loop would reach a
68
+ # running tool directly; until then the flag is polled from the context.
69
+ until wait_thread.join(POLL_SECONDS)
70
+ return :cancelled if cancel_flag&.set?
71
+ return :timed_out if monotonic_now >= deadline
72
+ end
73
+ :exited
74
+ end
75
+
76
+ # @rbs return: Float
77
+ def monotonic_now
78
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
58
79
  end
59
80
 
60
81
  # @rbs pid: Integer
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Animated output is gated behind an interactive, colour-enabled TTY, so piped
4
- # or tested runs stay silent and escape-free.
5
3
  class Riffer::Rig::UI::Animator
6
4
  REVEAL_FRAME_SECONDS = 0.05 #: Float
7
5
 
@@ -41,8 +39,6 @@ class Riffer::Rig::UI::Animator
41
39
  @phrase = nil
42
40
  end
43
41
 
44
- # When not on a TTY, prints the final frame once instead of animating.
45
- #
46
42
  # @rbs frames: Array[Array[String]]
47
43
  # @rbs return: void
48
44
  def reveal(frames)
@@ -104,7 +100,7 @@ class Riffer::Rig::UI::Animator
104
100
  # @rbs return: void
105
101
  def animate
106
102
  tick = 0
107
- roll_at = 0.0 # force an immediate phrase roll when entering reasoning mode
103
+ roll_at = 0.0
108
104
  until @stop
109
105
  roll_at = roll_phrase(roll_at)
110
106
  @io.print("\r #{equalizer(tick, label)}\e[K")
@@ -114,8 +110,6 @@ class Riffer::Rig::UI::Animator
114
110
  end
115
111
  end
116
112
 
117
- # Re-rolls both phrase and duration whenever the reasoning tick expires.
118
- #
119
113
  # @rbs roll_at: Float
120
114
  # @rbs return: Float
121
115
  def roll_phrase(roll_at)
@@ -126,11 +120,10 @@ class Riffer::Rig::UI::Animator
126
120
  now + roll_rand
127
121
  end
128
122
 
129
- # Range rand returns nil for an empty range; this one is a non-empty constant.
130
- #
131
123
  # @rbs return: Integer
132
124
  def roll_rand
133
125
  x = rand(REASONING_TICK_RANGE)
126
+ # Range rand returns nil for an empty range; this one is a non-empty constant.
134
127
  x || 0
135
128
  end
136
129
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Hides the terminal cursor while a turn is in flight: a hidden cursor can't
4
- # flicker against the animator's erase-and-redraw churn.
5
3
  class Riffer::Rig::UI::Cursor
6
4
  HIDE = "\e[?25l" #: String
7
5
 
@@ -39,28 +39,24 @@ class Riffer::Rig::UI::Renderer
39
39
  when Riffer::StreamEvents::Interrupt
40
40
  render_block(0) { @theme.dim("[interrupted: #{event.reason}]") }
41
41
  when Riffer::StreamEvents::TokenUsageDone
42
+ # Usage arrives per model call but tool results arrive via the session
43
+ # callback after each call's stream ends, so rendering inline would print
44
+ # the stats above the results they belong with.
42
45
  current = @deferred_usage
43
46
  @deferred_usage = current ? current + event.token_usage : event.token_usage
44
47
  end
45
48
  end
46
49
 
47
- # The prompt holds the line open for typed input, so unlike render_block it
48
- # ends without a newline. It sits at the renderer so a tool group left open by
49
- # the previous turn closes here, and the next turn's blocks open cleanly.
50
- #
51
50
  # @rbs return: void
52
51
  def prompt
52
+ # Closes any tool group the previous turn left open, so the next turn's
53
+ # blocks open cleanly.
53
54
  @prose_gap_pending = true
54
55
  @io.puts
55
56
  @io.print("#{@theme.pink('›')} ")
56
57
  @io.flush
57
58
  end
58
59
 
59
- # Usage arrives per model call but tool results arrive via the session
60
- # callback after each call's stream ends, so rendering inline would print the
61
- # stats above the results they belong with. Held until the turn's output is
62
- # done, then printed as one line below it.
63
- #
64
60
  # @rbs return: void
65
61
  def flush_usage
66
62
  usage = @deferred_usage
@@ -86,11 +82,6 @@ class Riffer::Rig::UI::Renderer
86
82
 
87
83
  private
88
84
 
89
- # Rule 2 for one-off blocks (skill line, interrupt, stats): one blank line
90
- # above, no indent. The smoother drains first so any pending partial prose
91
- # block ends cleanly before the gap is written, and any open tool group closes
92
- # so the next group opens with its own gap.
93
- #
94
85
  # @rbs indent: Integer
95
86
  # @rbs &block: () -> String
96
87
  # @rbs return: void
@@ -102,13 +93,11 @@ class Riffer::Rig::UI::Renderer
102
93
  @io.flush
103
94
  end
104
95
 
105
- # The blank line above a prose block is written at the first delta after a
106
- # non-prose block. Streaming makes "which block is first?" a stateful
107
- # question, so the answer is tracked rather than embedded at each render site.
108
- #
109
96
  # @rbs content: String
110
97
  # @rbs return: void
111
98
  def render_prose(content)
99
+ # Streaming makes "which delta opens a prose block?" a stateful question,
100
+ # so the gap is tracked in a flag rather than written at each render site.
112
101
  if @prose_gap_pending
113
102
  @prose_gap_pending = false
114
103
  @io.print("\n")
@@ -125,12 +114,10 @@ class Riffer::Rig::UI::Renderer
125
114
  @io.flush
126
115
  end
127
116
 
128
- # Tool-activity lines (⚙ calls, ↳ results) share one blank line above the
129
- # group instead of one between each line — the gap goes between groups, not
130
- # inside a pair. The group stays open so prose after it pays the closing gap.
131
- #
132
117
  # @rbs return: void
133
118
  def open_tool_activity
119
+ # Tool-activity lines (⚙ calls, ↳ results) share one blank line above the
120
+ # group; the group stays open so the prose after it pays the closing gap.
134
121
  return if @prose_gap_pending
135
122
 
136
123
  drain_smoother
@@ -157,8 +144,6 @@ class Riffer::Rig::UI::Renderer
157
144
  @smoother || PassThroughSmoother.new(@io)
158
145
  end
159
146
 
160
- # Stand-in when no smoother is injected, so a bare Riffer::Rig::UI::Renderer still prints
161
- # synchronously.
162
147
  class PassThroughSmoother
163
148
  # @rbs @io: IO
164
149
 
@@ -1,20 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Paces streamed text onto a 60fps cadence so API bursts read as continuous
4
- # typing. On a non-TTY output it is transparent: writes pass through
5
- # synchronously and no thread ever runs.
6
3
  class Riffer::Rig::UI::Smoother
7
4
  TICK_SECONDS = 1.0 / 60.0 #: Float
8
5
 
9
- # A frame removes only a sixtieth of the remaining backlog — an exponential
10
- # decay whose tail keeps text flowing for a second or more, so the reveal
11
- # never visibly stops between chunks. The carry accrues sub-character
12
- # releases so the effective rate can settle at the provider's throughput;
13
- # the floor is fractional (10 chars/s) so it can't over-drain a backlog
14
- # that's trickling in slower than 60 chars/s.
15
- #
6
+ # Releasing a fraction of the backlog, not a fixed rate, makes the reveal an
7
+ # exponential decay: its tail keeps text flowing for a second or more, so
8
+ # output never visibly stops between chunks.
16
9
  BACKLOG_FRACTION_PER_TICK = Rational(1, 60) #: Rational
17
10
 
11
+ # Fractional (10 chars/s) so the floor can't over-drain a backlog that's
12
+ # trickling in slower than 60 chars/s.
18
13
  MIN_CHARS_PER_TICK = Rational(1, 6) #: Rational
19
14
 
20
15
  # @rbs @io: IO
@@ -72,13 +67,13 @@ class Riffer::Rig::UI::Smoother
72
67
  self
73
68
  end
74
69
 
75
- # The write sits inside the mutex so a concurrent tick can't reorder a drain.
76
- #
77
70
  # @rbs return: void
78
71
  def tick
79
72
  @mutex.synchronize do
80
73
  return if @backlog.empty?
81
74
 
75
+ # The carry accrues sub-character releases so the effective rate can
76
+ # settle at the provider's throughput.
82
77
  @carry += [@backlog.length * BACKLOG_FRACTION_PER_TICK, MIN_CHARS_PER_TICK].max
83
78
  count = @carry.floor
84
79
  return if count.zero?
@@ -86,16 +81,12 @@ class Riffer::Rig::UI::Smoother
86
81
  @carry -= count
87
82
  chunk = @backlog.slice!(0, count) || ''
88
83
  @newline_pending = !chunk.end_with?("\n")
84
+ # Written inside the mutex so a concurrent drain can't reorder output.
89
85
  @io.print(chunk)
90
86
  @io.flush
91
87
  end
92
88
  end
93
89
 
94
- # Called before non-prose output so printed order matches stream order. The
95
- # backlog is flushed, then any partial prose block the model left without a
96
- # trailing newline gets one, so block spacing never depends on the model's
97
- # last character.
98
- #
99
90
  # @rbs return: void
100
91
  def drain
101
92
  @mutex.synchronize do
@@ -106,16 +97,17 @@ class Riffer::Rig::UI::Smoother
106
97
  end
107
98
  return unless @newline_pending
108
99
 
100
+ # Block spacing must not depend on whether the model ended its prose
101
+ # with a newline.
109
102
  @newline_pending = false
110
103
  @io.print("\n")
111
104
  @io.flush
112
105
  end
113
106
  end
114
107
 
115
- # Idempotent: the REPL's ensure path runs it even after a happy-path finish.
116
- #
117
108
  # @rbs return: void
118
109
  def finish
110
+ # Idempotent: the REPL's ensure path runs it even after a happy-path finish.
119
111
  thread = @thread
120
112
  if thread
121
113
  @stop = true
@@ -1,11 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # +enabled+ gates every escape so the same code path emits plain, escape-free
4
- # text when piped or tested.
5
3
  class Riffer::Rig::UI::Theme
6
4
  # @rbs @enabled: bool
7
5
 
8
- # @rbs return: bool
9
6
  # @dynamic enabled
10
7
  attr_reader :enabled #: bool
11
8
 
@@ -16,9 +13,10 @@ class Riffer::Rig::UI::Theme
16
13
  end
17
14
 
18
15
  # @rbs io: IO
16
+ # @rbs env: Riffer::Rig::Env
19
17
  # @rbs return: Riffer::Rig::UI::Theme
20
- def self.for(io)
21
- new(enabled: io.tty? && !ENV.key?('NO_COLOR'))
18
+ def self.for(io, env: Riffer::Rig::Env.new)
19
+ new(enabled: io.tty? && !env.no_color)
22
20
  end
23
21
 
24
22
  # @rbs text: String
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Riffer
4
4
  module Rig
5
- VERSION = '0.5.0'
5
+ VERSION = '0.6.0'
6
6
  end
7
7
  end
data/lib/riffer/rig.rb CHANGED
@@ -13,13 +13,6 @@ module Riffer::Rig
13
13
  # @rbs self.@extensions: Hash[String, Riffer::Rig::Extension]
14
14
  @extensions = {} #: Hash[String, Riffer::Rig::Extension]
15
15
 
16
- # Records an extension block in the process registry and returns the
17
- # extension object. Re-recording a name replaces the block.
18
- #
19
- # Riffer::Rig.extension('git') do |rig|
20
- # rig.tool GitLog
21
- # end
22
- #
23
16
  # @rbs name: String
24
17
  # @rbs requires: String?
25
18
  # @rbs &block: (::Riffer::Rig::Registrar) -> void
@@ -31,6 +24,8 @@ module Riffer::Rig
31
24
  "extension #{name} requires riffer-rig #{ext.requires}, found #{Riffer::Rig::VERSION}"
32
25
  end
33
26
 
27
+ # Keyed by name so reloading a file replaces its block rather than
28
+ # duplicating it.
34
29
  @extensions[name] = ext
35
30
  end
36
31
 
@@ -1,30 +1,38 @@
1
1
  # Generated from lib/riffer/rig/cli.rb with RBS::Inline
2
2
 
3
3
  module Riffer::Rig::CLI
4
- PROVIDER_URLS: Hash[String, String]
5
-
6
4
  # @rbs output: IO
7
5
  # @rbs input: IO
6
+ # @rbs model: String
7
+ # @rbs env: Riffer::Rig::Env
8
8
  # @rbs return: Integer
9
- def start: (?output: IO, ?input: IO) -> Integer
9
+ def start: (?output: IO, ?input: IO, ?model: String, ?env: Riffer::Rig::Env) -> Integer
10
10
 
11
11
  private
12
12
 
13
- # @rbs provider: String?
14
- # @rbs api_key: String
15
- # @rbs return: void
16
- def configure_provider: (String? provider, String api_key) -> void
13
+ # @rbs provider: String
14
+ # @rbs theme: Riffer::Rig::UI::Theme
15
+ # @rbs output: IO
16
+ # @rbs input: IO
17
+ # @rbs env: Riffer::Rig::Env
18
+ # @rbs return: Hash[Symbol, String]?
19
+ def credentials_for: (String provider, Riffer::Rig::UI::Theme theme, output: IO, input: IO, env: Riffer::Rig::Env) -> Hash[Symbol, String]?
17
20
 
18
- # @rbs provider: String?
21
+ # @rbs provider: String
22
+ # @rbs resolution: Riffer::Rig::Credentials::Resolution
19
23
  # @rbs theme: Riffer::Rig::UI::Theme
20
24
  # @rbs output: IO
21
25
  # @rbs input: IO
22
- # @rbs return: String?
23
- def onboard: (String? provider, Riffer::Rig::UI::Theme theme, output: IO, input: IO) -> String?
26
+ # @rbs return: Hash[Symbol, String]?
27
+ def onboard: (String provider, Riffer::Rig::Credentials::Resolution resolution, Riffer::Rig::UI::Theme theme, output: IO, input: IO) -> Hash[Symbol, String]?
24
28
 
25
- # @rbs provider: String?
29
+ # @rbs provider: String
30
+ # @rbs field: Riffer::Rig::ProviderSetup::Field
31
+ # @rbs theme: Riffer::Rig::UI::Theme
32
+ # @rbs output: IO
33
+ # @rbs input: IO
26
34
  # @rbs return: String
27
- def env_var_for: (String? provider) -> String
35
+ def prompt: (String provider, Riffer::Rig::ProviderSetup::Field field, Riffer::Rig::UI::Theme theme, output: IO, input: IO) -> String
28
36
 
29
37
  # @rbs input: IO
30
38
  # @rbs return: String?
@@ -0,0 +1,13 @@
1
+ # Generated from lib/riffer/rig/credentials/resolution.rb with RBS::Inline
2
+
3
+ class Riffer::Rig::Credentials::Resolution
4
+ # @dynamic values, missing
5
+ attr_reader values: Hash[Symbol, String]
6
+
7
+ attr_reader missing: Array[Symbol]
8
+
9
+ # @rbs values: Hash[Symbol, String]
10
+ # @rbs missing: Array[Symbol]
11
+ # @rbs return: void
12
+ def initialize: (values: Hash[Symbol, String], missing: Array[Symbol]) -> void
13
+ end