riffer-rig 0.3.0 → 0.5.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/lib/riffer/rig/cli.rb +16 -8
  4. data/lib/riffer/rig/coding_agent.rb +31 -34
  5. data/lib/riffer/rig/credentials.rb +7 -7
  6. data/lib/riffer/rig/extension.rb +31 -0
  7. data/lib/riffer/rig/host.rb +52 -0
  8. data/lib/riffer/rig/registrar.rb +23 -0
  9. data/lib/riffer/rig/repl.rb +63 -59
  10. data/lib/riffer/rig/runtime.rb +162 -0
  11. data/lib/riffer/rig/settings/document.rb +23 -0
  12. data/lib/riffer/rig/settings/pricing.rb +11 -0
  13. data/lib/riffer/rig/settings.rb +9 -17
  14. data/lib/riffer/rig/token_tally.rb +1 -1
  15. data/lib/riffer/rig/tools/bash.rb +5 -2
  16. data/lib/riffer/rig/tools/edit.rb +11 -3
  17. data/lib/riffer/rig/tools/read.rb +2 -2
  18. data/lib/riffer/rig/ui/animator.rb +6 -6
  19. data/lib/riffer/rig/ui/cursor.rb +4 -4
  20. data/lib/riffer/rig/ui/renderer.rb +115 -36
  21. data/lib/riffer/rig/ui/smoother.rb +29 -13
  22. data/lib/riffer/rig/ui/theme.rb +2 -2
  23. data/lib/riffer/rig/version.rb +1 -1
  24. data/lib/riffer/rig.rb +31 -0
  25. data/sig/generated/riffer/rig/cli.rbs +8 -8
  26. data/sig/generated/riffer/rig/credentials.rbs +4 -3
  27. data/sig/generated/riffer/rig/extension.rbs +28 -0
  28. data/sig/generated/riffer/rig/host.rbs +42 -0
  29. data/sig/generated/riffer/rig/registrar.rbs +17 -0
  30. data/sig/generated/riffer/rig/repl.rbs +24 -23
  31. data/sig/generated/riffer/rig/runtime.rbs +101 -0
  32. data/sig/generated/riffer/rig/settings/document.rbs +20 -0
  33. data/sig/generated/riffer/rig/settings/pricing.rbs +4 -0
  34. data/sig/generated/riffer/rig/settings.rbs +5 -5
  35. data/sig/generated/riffer/rig/token_tally.rbs +2 -2
  36. data/sig/generated/riffer/rig/tools/bash.rbs +2 -2
  37. data/sig/generated/riffer/rig/tools/edit.rbs +2 -2
  38. data/sig/generated/riffer/rig/tools/read.rbs +3 -3
  39. data/sig/generated/riffer/rig/ui/animator.rbs +8 -8
  40. data/sig/generated/riffer/rig/ui/cursor.rbs +4 -4
  41. data/sig/generated/riffer/rig/ui/renderer.rbs +65 -15
  42. data/sig/generated/riffer/rig/ui/smoother.rbs +10 -5
  43. data/sig/generated/riffer/rig/ui/theme.rbs +2 -2
  44. data/sig/generated/riffer/rig.rbs +19 -0
  45. data/sig/manual/riffer/rig.rbs +11 -0
  46. metadata +24 -7
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ # One Runtime builds its own Riffer::Agent from a per-instance
6
+ # Riffer::Agent::Config and runs a prompt: streamed as events, or run to
7
+ # completion and returned as riffer's Agent::Response.
8
+ #
9
+ # runtime = Riffer::Rig::Runtime.new('mock/x')
10
+ # runtime.prompt('hello') { |event| ... } # yields riffer StreamEvents
11
+ # runtime.prompt('hello').each { |event| } # an Enumerator without a block
12
+ # response = runtime.ask('hello') # a Riffer::Agent::Response
13
+ #
14
+ # Two Runtimes in one process share nothing but the process-wide extension
15
+ # registry and riffer's provider repository. One prompt runs at a time; a
16
+ # second while one is running raises Riffer::Rig::Runtime::BusyError. The
17
+ # Runtime never renders, never prints, never reads the filesystem.
18
+ class Riffer::Rig::Runtime
19
+ # Raised when a second prompt, ask or registrar build runs while one is
20
+ # already running on this Runtime.
21
+ class BusyError < StandardError; end
22
+
23
+ BASE_PROMPT_TEMPLATE = <<~TEXT
24
+ You are %<name>s, a general-purpose agent. You work by using the tools you have
25
+ been given; each tool describes what it does and when to use it.
26
+
27
+ - Verify with your tools before answering. When a tool can settle a question,
28
+ look rather than guess.
29
+ - Do what was asked, and all of what was asked. Do not widen the scope, tidy
30
+ nearby things, or add extras that were not requested.
31
+ - Be concise. Lead with the outcome; do not restate the question.
32
+ TEXT
33
+
34
+ DEFAULT_NAME = 'riffer'
35
+
36
+ # The legacy CodingAgent's default: an unlimited agent loop. riffer's own
37
+ # default (16) is too small for a general-purpose harness.
38
+ DEFAULT_MAX_STEPS = nil #: Integer?
39
+
40
+ # @rbs!
41
+ # interface _Host
42
+ # def ask: (?String, ?options: Array[String]?, ?secret: bool) -> String?
43
+ # def confirm: (?String) -> bool
44
+ # def notify: (?String, ?level: Symbol) -> void
45
+ # def progress: (?String) { () -> void } -> void
46
+ # def capabilities: () -> Set[Symbol]
47
+ # end
48
+
49
+ # @rbs @agent: Riffer::Agent
50
+ # @rbs @cwd: String
51
+ # @rbs @host: _Host
52
+ # @rbs @settings: Hash[Symbol, untyped]
53
+ # @rbs @busy: bool
54
+ # @rbs @registrar: Riffer::Rig::Registrar
55
+
56
+ # @dynamic agent, cwd, host, settings
57
+ attr_reader :agent #: Riffer::Agent
58
+ attr_reader :cwd #: String
59
+ attr_reader :host #: _Host
60
+ attr_reader :settings #: Hash[Symbol, untyped]
61
+
62
+ # @rbs model: String
63
+ # @rbs extensions: Array[Riffer::Rig::Extension]
64
+ # @rbs tools: Array[String]?
65
+ # @rbs settings: Hash[Symbol, untyped]
66
+ # @rbs host: _Host
67
+ # @rbs cwd: String?
68
+ # @rbs name: String
69
+ # @rbs instructions: String?
70
+ # @rbs credentials: Hash[String, String]
71
+ # @rbs pricing: Hash[String, Riffer::Rig::Settings::Pricing]
72
+ # @rbs max_steps: Integer?
73
+ # @rbs snapshot: Hash[Symbol, untyped]?
74
+ # @rbs return: void
75
+ def initialize(
76
+ model,
77
+ extensions: [],
78
+ tools: nil,
79
+ settings: {},
80
+ host: Riffer::Rig::Host.new,
81
+ cwd: nil,
82
+ name: DEFAULT_NAME,
83
+ instructions: nil,
84
+ credentials: {},
85
+ pricing: {},
86
+ max_steps: DEFAULT_MAX_STEPS,
87
+ snapshot: nil
88
+ )
89
+ @host = host
90
+ @cwd = cwd || Dir.pwd
91
+ @settings = settings
92
+
93
+ @busy = false
94
+ @registrar = build_registrar(extensions)
95
+ tool_classes = select_tools(@registrar.tools, tools)
96
+ base_prompt = instructions || format(BASE_PROMPT_TEMPLATE, name: name)
97
+
98
+ @agent = Riffer::Agent.new(
99
+ config: Riffer::Agent::Config.new(
100
+ model: model,
101
+ instructions: system_prompt(base_prompt),
102
+ tools_config: tool_classes,
103
+ max_steps: max_steps
104
+ )
105
+ )
106
+ end
107
+
108
+ # @rbs text: String
109
+ # @rbs &block: ?(Riffer::StreamEvents::Base) -> void
110
+ # @rbs return: (nil | Enumerator[Riffer::StreamEvents::Base, void])
111
+ def prompt(text, &block)
112
+ raise BusyError, 'a prompt is already running on this Runtime' if @busy
113
+
114
+ @busy = true
115
+ if block
116
+ @agent.stream(text).each(&block)
117
+ nil
118
+ else
119
+ @agent.stream(text)
120
+ end
121
+ ensure
122
+ @busy = false
123
+ end
124
+
125
+ # @rbs text: String
126
+ # @rbs return: Riffer::Agent::Response
127
+ def ask(text)
128
+ raise BusyError, 'a prompt is already running on this Runtime' if @busy
129
+
130
+ @busy = true
131
+ @agent.stream(text).each { |event| event }
132
+ ensure
133
+ @busy = false
134
+ end
135
+
136
+ private
137
+
138
+ # @rbs extensions: Array[Riffer::Rig::Extension]
139
+ # @rbs return: Riffer::Rig::Registrar
140
+ def build_registrar(extensions)
141
+ raise BusyError, 'a prompt is already running on this Runtime' if @busy
142
+
143
+ extensions.each_with_object(Riffer::Rig::Registrar.new) do |extension, registrar|
144
+ extension.run(registrar)
145
+ end
146
+ end
147
+
148
+ # @rbs registered: Array[singleton(Riffer::Tool)]
149
+ # @rbs allowlist: Array[String]?
150
+ # @rbs return: Array[singleton(Riffer::Tool)]
151
+ def select_tools(registered, allowlist)
152
+ return registered if allowlist.nil?
153
+
154
+ registered.select { |klass| allowlist.include?(klass.name) }
155
+ end
156
+
157
+ # @rbs base_prompt: String
158
+ # @rbs return: String
159
+ def system_prompt(base_prompt)
160
+ "#{base_prompt}\n\nCurrent date: #{Date.today}\nCurrent working directory: #{@cwd}"
161
+ end
162
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Settings::Document
4
+ # @rbs @model: String?
5
+ # @rbs @reasoning: String?
6
+ # @rbs @models: Hash[String, Riffer::Rig::Settings::Pricing]
7
+
8
+ # @dynamic model, reasoning, models
9
+ attr_reader :model #: String?
10
+ attr_reader :reasoning #: String?
11
+ attr_reader :models #: Hash[String, Riffer::Rig::Settings::Pricing]
12
+
13
+ # @rbs source: untyped
14
+ # @rbs return: void
15
+ def initialize(source)
16
+ @model = source['model'].is_a?(String) ? source['model'] : nil
17
+ @reasoning = source['reasoning'].is_a?(String) ? source['reasoning'] : nil
18
+ entries = source['models'].is_a?(Hash) ? source['models'] : {} #: Hash[String, untyped]
19
+ @models = entries.filter_map do |name, entry|
20
+ [name, Riffer::Rig::Settings::Pricing.from(entry)] if name.is_a?(String) && entry.is_a?(Hash)
21
+ end.to_h
22
+ end
23
+ end
@@ -5,6 +5,17 @@ class Riffer::Rig::Settings::Pricing
5
5
  # @dynamic input, output, cache_write, cache_read
6
6
  attr_reader :input, :output, :cache_write, :cache_read #: Float
7
7
 
8
+ # @rbs entry: Hash[String, untyped]
9
+ # @rbs return: ::Riffer::Rig::Settings::Pricing
10
+ def self.from(entry)
11
+ new(
12
+ input: entry.fetch('input', 0).to_f,
13
+ output: entry.fetch('output', 0).to_f,
14
+ cache_write: entry.fetch('cache_write', 0).to_f,
15
+ cache_read: entry.fetch('cache_read', 0).to_f
16
+ )
17
+ end
18
+
8
19
  # @rbs input: Float
9
20
  # @rbs output: Float
10
21
  # @rbs cache_write: Float
@@ -51,7 +51,7 @@ module Riffer::Rig::Settings
51
51
  # @rbs path: String
52
52
  # @rbs return: String
53
53
  def model(path: PATH)
54
- read(path).fetch('model', DEFAULT_MODEL)
54
+ read(path).model || DEFAULT_MODEL
55
55
  end
56
56
 
57
57
  # Returns model options for the configured model and reasoning level, ready
@@ -80,15 +80,7 @@ module Riffer::Rig::Settings
80
80
  # @rbs path: String
81
81
  # @rbs return: Pricing?
82
82
  def pricing_for(model, path: PATH)
83
- entry = read(path).dig('models', model)
84
- return nil unless entry.is_a?(Hash)
85
-
86
- Pricing.new(
87
- input: entry.fetch('input', 0).to_f,
88
- output: entry.fetch('output', 0).to_f,
89
- cache_write: entry.fetch('cache_write', 0).to_f,
90
- cache_read: entry.fetch('cache_read', 0).to_f
91
- )
83
+ read(path).models[model]
92
84
  end
93
85
 
94
86
  private
@@ -102,10 +94,10 @@ module Riffer::Rig::Settings
102
94
  end
103
95
 
104
96
  # @rbs path: String
105
- # @rbs ?provider: String?
97
+ # @rbs provider: String?
106
98
  # @rbs return: String?
107
99
  def reasoning_for(path: PATH, provider: nil)
108
- level = read(path)['reasoning']
100
+ level = read(path).reasoning
109
101
  valid_levels = (provider && REASONING_LEVELS_BY_PROVIDER[provider]) || []
110
102
  valid_levels.include?(level) ? level : nil
111
103
  end
@@ -129,16 +121,16 @@ module Riffer::Rig::Settings
129
121
  end
130
122
  end
131
123
 
132
- # The parsed settings file, or an empty hash when the file is absent or
124
+ # The parsed settings file, or an empty document when the file is absent or
133
125
  # malformed.
134
126
  #
135
127
  # @rbs path: String
136
- # @rbs return: Hash[String, untyped]
128
+ # @rbs return: Document
137
129
  def read(path)
138
- return {} unless File.file?(path)
130
+ return Document.new({}) unless File.file?(path)
139
131
 
140
- JSON.parse(File.read(path))
132
+ Document.new(JSON.parse(File.read(path)))
141
133
  rescue JSON::ParserError
142
- {}
134
+ Document.new({})
143
135
  end
144
136
  end
@@ -22,7 +22,7 @@ class Riffer::Rig::TokenTally
22
22
  # @dynamic input_tokens, output_tokens, cache_write_tokens, cache_read_tokens
23
23
  attr_reader :input_tokens, :output_tokens, :cache_write_tokens, :cache_read_tokens
24
24
 
25
- # @rbs ?pricing: Riffer::Rig::Settings::Pricing?
25
+ # @rbs pricing: Riffer::Rig::Settings::Pricing?
26
26
  # @rbs return: void
27
27
  def initialize(pricing: nil)
28
28
  @pricing = pricing
@@ -15,12 +15,15 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
15
15
 
16
16
  params do
17
17
  required :command, String, description: 'The shell command to run'
18
- optional :timeout_ms, Integer, description: 'Kill the command after this many milliseconds', default: DEFAULT_TIMEOUT_MS
18
+ optional :timeout_ms,
19
+ Integer,
20
+ description: 'Kill the command after this many milliseconds',
21
+ default: DEFAULT_TIMEOUT_MS
19
22
  end
20
23
 
21
24
  # @rbs context: Riffer::Agent::Context?
22
25
  # @rbs command: String
23
- # @rbs ?timeout_ms: Integer
26
+ # @rbs timeout_ms: Integer
24
27
  # @rbs return: Riffer::Tools::Response
25
28
  def call(context:, command:, timeout_ms: DEFAULT_TIMEOUT_MS)
26
29
  output, status = run(command, timeout_ms / 1000.0)
@@ -9,14 +9,17 @@ class Riffer::Rig::Tools::Edit < Riffer::Tool
9
9
  required :path, String, description: 'Path to the file (absolute, or relative to the working directory)'
10
10
  required :old_string, String, description: 'The exact text to replace'
11
11
  required :new_string, String, description: 'The text to replace it with'
12
- optional :replace_all, Riffer::Params::Boolean, description: 'Replace all occurrences instead of requiring a unique match', default: false
12
+ optional :replace_all,
13
+ Riffer::Params::Boolean,
14
+ description: 'Replace all occurrences instead of requiring a unique match',
15
+ default: false
13
16
  end
14
17
 
15
18
  # @rbs context: Riffer::Agent::Context?
16
19
  # @rbs path: String
17
20
  # @rbs old_string: String
18
21
  # @rbs new_string: String
19
- # @rbs ?replace_all: bool
22
+ # @rbs replace_all: bool
20
23
  # @rbs return: Riffer::Tools::Response
21
24
  def call(context:, path:, old_string:, new_string:, replace_all: false)
22
25
  resolved = File.expand_path(path, Dir.pwd)
@@ -26,7 +29,12 @@ class Riffer::Rig::Tools::Edit < Riffer::Tool
26
29
  occurrences = content.scan(old_string).length
27
30
 
28
31
  return error("old_string not found in #{path}", type: :not_found) if occurrences.zero?
29
- return error("old_string is not unique in #{path} (#{occurrences} matches). Pass replace_all or add more context.", type: :ambiguous_match) if occurrences > 1 && !replace_all
32
+ if occurrences > 1 && !replace_all
33
+ return error(
34
+ "old_string is not unique in #{path} (#{occurrences} matches). Pass replace_all or add more context.",
35
+ type: :ambiguous_match
36
+ )
37
+ end
30
38
 
31
39
  # Block form so backslash sequences in new_string (\0, \1, \\) are inserted
32
40
  # literally instead of being interpreted as regexp backreferences.
@@ -15,8 +15,8 @@ class Riffer::Rig::Tools::Read < Riffer::Tool
15
15
 
16
16
  # @rbs context: Riffer::Agent::Context?
17
17
  # @rbs path: String
18
- # @rbs ?offset: Integer?
19
- # @rbs ?limit: Integer
18
+ # @rbs offset: Integer?
19
+ # @rbs limit: Integer
20
20
  # @rbs return: Riffer::Tools::Response
21
21
  def call(context:, path:, offset: nil, limit: DEFAULT_LIMIT)
22
22
  resolved = File.expand_path(path, Dir.pwd)
@@ -23,15 +23,15 @@ class Riffer::Rig::UI::Animator
23
23
 
24
24
  REASONING_TICK_RANGE = (1..5) #: Range[Integer]
25
25
 
26
- # @rbs @io: untyped
26
+ # @rbs @io: IO
27
27
  # @rbs @theme: Riffer::Rig::UI::Theme
28
28
  # @rbs @thread: Thread?
29
29
  # @rbs @mode: Symbol
30
30
  # @rbs @phrase: String?
31
31
  # @rbs @stop: bool
32
32
 
33
- # @rbs io: untyped
34
- # @rbs ?theme: Riffer::Rig::UI::Theme
33
+ # @rbs io: IO
34
+ # @rbs theme: Riffer::Rig::UI::Theme
35
35
  # @rbs return: void
36
36
  def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io))
37
37
  @io = io
@@ -60,7 +60,7 @@ class Riffer::Rig::UI::Animator
60
60
  end
61
61
  end
62
62
 
63
- # @rbs ?mode: Symbol
63
+ # @rbs mode: Symbol
64
64
  # @rbs return: void
65
65
  def start(mode = :neutral)
66
66
  return unless enabled?
@@ -88,7 +88,7 @@ class Riffer::Rig::UI::Animator
88
88
  end
89
89
 
90
90
  # @rbs tick: Integer
91
- # @rbs ?label: String
91
+ # @rbs label: String
92
92
  # @rbs return: String
93
93
  def equalizer(tick, label = NEUTRAL_LABEL)
94
94
  bars = Array.new(EQ_BARS) do |i|
@@ -141,6 +141,6 @@ class Riffer::Rig::UI::Animator
141
141
 
142
142
  # @rbs return: bool
143
143
  def enabled?
144
- @theme.enabled && @io.respond_to?(:tty?) && @io.tty?
144
+ @theme.enabled && @io.tty?
145
145
  end
146
146
  end
@@ -7,11 +7,11 @@ class Riffer::Rig::UI::Cursor
7
7
 
8
8
  SHOW = "\e[?25h" #: String
9
9
 
10
- # @rbs @io: untyped
10
+ # @rbs @io: IO
11
11
  # @rbs @theme: Riffer::Rig::UI::Theme
12
12
 
13
- # @rbs io: untyped
14
- # @rbs ?theme: Riffer::Rig::UI::Theme
13
+ # @rbs io: IO
14
+ # @rbs theme: Riffer::Rig::UI::Theme
15
15
  # @rbs return: void
16
16
  def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io))
17
17
  @io = io
@@ -41,6 +41,6 @@ class Riffer::Rig::UI::Cursor
41
41
 
42
42
  # @rbs return: bool
43
43
  def enabled?
44
- @theme.enabled && @io.respond_to?(:tty?) && @io.tty?
44
+ @theme.enabled && @io.tty?
45
45
  end
46
46
  end
@@ -5,21 +5,25 @@ require 'json'
5
5
  class Riffer::Rig::UI::Renderer
6
6
  RESULT_PREVIEW_LIMIT = 200 #: Integer
7
7
 
8
- # @rbs @io: untyped
8
+ # @rbs @io: IO
9
9
  # @rbs @theme: Riffer::Rig::UI::Theme
10
10
  # @rbs @tally: Riffer::Rig::TokenTally?
11
11
  # @rbs @smoother: Riffer::Rig::UI::Smoother | PassThroughSmoother?
12
+ # @rbs @deferred_usage: Riffer::Providers::TokenUsage?
13
+ # @rbs @prose_gap_pending: bool
12
14
 
13
- # @rbs io: untyped
14
- # @rbs ?theme: Riffer::Rig::UI::Theme
15
- # @rbs ?tally: Riffer::Rig::TokenTally?
16
- # @rbs ?smoother: Riffer::Rig::UI::Smoother | PassThroughSmoother?
15
+ # @rbs io: IO
16
+ # @rbs theme: Riffer::Rig::UI::Theme
17
+ # @rbs tally: Riffer::Rig::TokenTally?
18
+ # @rbs smoother: Riffer::Rig::UI::Smoother | PassThroughSmoother?
17
19
  # @rbs return: void
18
20
  def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io), tally: nil, smoother: nil)
19
21
  @io = io
20
22
  @theme = theme
21
23
  @tally = tally
22
24
  @smoother = smoother
25
+ @deferred_usage = nil
26
+ @prose_gap_pending = false
23
27
  end
24
28
 
25
29
  # @rbs event: Riffer::StreamEvents::Base
@@ -27,33 +31,127 @@ class Riffer::Rig::UI::Renderer
27
31
  def render(event)
28
32
  case event
29
33
  when Riffer::StreamEvents::TextDelta
30
- smoother << event.content
34
+ render_prose(event.content)
31
35
  when Riffer::StreamEvents::ToolCallDone
32
- drain_smoother
33
- @io.puts("\n#{@theme.cyan("⚙ #{event.name}(#{format_arguments(event.arguments)})")}")
36
+ render_tool_activity(2) { @theme.cyan("⚙ #{event.name}(#{format_arguments(event.arguments)})") }
34
37
  when Riffer::StreamEvents::SkillActivation
35
- drain_smoother
36
- @io.puts("\n#{@theme.magenta("✦ skill: #{event.name}")}")
38
+ render_block(0) { @theme.magenta("✦ skill: #{event.name}") }
37
39
  when Riffer::StreamEvents::Interrupt
38
- drain_smoother
39
- @io.puts(@theme.dim("[interrupted: #{event.reason}]"))
40
+ render_block(0) { @theme.dim("[interrupted: #{event.reason}]") }
40
41
  when Riffer::StreamEvents::TokenUsageDone
41
- drain_smoother
42
- render_token_usage(event.token_usage)
42
+ current = @deferred_usage
43
+ @deferred_usage = current ? current + event.token_usage : event.token_usage
43
44
  end
44
45
  end
45
46
 
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
+ # @rbs return: void
52
+ def prompt
53
+ @prose_gap_pending = true
54
+ @io.puts
55
+ @io.print("#{@theme.pink('›')} ")
56
+ @io.flush
57
+ end
58
+
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
+ # @rbs return: void
65
+ def flush_usage
66
+ usage = @deferred_usage
67
+ @deferred_usage = nil
68
+ tally = @tally
69
+ return unless usage && tally
70
+
71
+ tally.add(usage)
72
+ render_block(0) { @theme.dim(usage_line(usage, tally)) }
73
+ end
74
+
46
75
  # @rbs message: Riffer::Messages::Base
47
76
  # @rbs return: void
48
77
  def render_tool_result(message)
49
78
  return unless message.is_a?(Riffer::Messages::Tool)
50
79
 
80
+ open_tool_activity
51
81
  line = "↳ #{preview(message.content)}"
52
- @io.puts(message.error? ? @theme.red(line) : @theme.dim(line))
82
+ styled = message.error? ? @theme.red(line) : @theme.dim(line)
83
+ @io.print(" #{styled}\n")
84
+ @io.flush
53
85
  end
54
86
 
55
87
  private
56
88
 
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
+ # @rbs indent: Integer
95
+ # @rbs &block: () -> String
96
+ # @rbs return: void
97
+ def render_block(indent, &)
98
+ drain_smoother
99
+ @io.puts
100
+ @prose_gap_pending = true
101
+ @io.puts((' ' * indent) + yield)
102
+ @io.flush
103
+ end
104
+
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
+ # @rbs content: String
110
+ # @rbs return: void
111
+ def render_prose(content)
112
+ if @prose_gap_pending
113
+ @prose_gap_pending = false
114
+ @io.print("\n")
115
+ end
116
+ smoother << content
117
+ end
118
+
119
+ # @rbs indent: Integer
120
+ # @rbs &block: () -> String
121
+ # @rbs return: void
122
+ def render_tool_activity(indent, &)
123
+ open_tool_activity
124
+ @io.puts((' ' * indent) + yield)
125
+ @io.flush
126
+ end
127
+
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
+ # @rbs return: void
133
+ def open_tool_activity
134
+ return if @prose_gap_pending
135
+
136
+ drain_smoother
137
+ @io.puts
138
+ @prose_gap_pending = true
139
+ end
140
+
141
+ # @rbs usage: Riffer::Providers::TokenUsage
142
+ # @rbs tally: Riffer::Rig::TokenTally
143
+ # @rbs return: String
144
+ def usage_line(usage, tally)
145
+ parts = ["↑#{usage.input_tokens}", "↓#{usage.output_tokens}"]
146
+ parts << "cache_write:#{usage.cache_write_tokens}" if usage.cache_write_tokens&.positive?
147
+ parts << "cache_read:#{usage.cache_read_tokens}" if usage.cache_read_tokens&.positive?
148
+ parts << "session #{tally.total_tokens} tok"
149
+ cost = tally.estimated_cost
150
+ parts << format('~$%.4f', cost) if cost
151
+
152
+ parts.join(' · ')
153
+ end
154
+
57
155
  # @rbs return: Riffer::Rig::UI::Smoother | PassThroughSmoother
58
156
  def smoother
59
157
  @smoother || PassThroughSmoother.new(@io)
@@ -62,9 +160,9 @@ class Riffer::Rig::UI::Renderer
62
160
  # Stand-in when no smoother is injected, so a bare Riffer::Rig::UI::Renderer still prints
63
161
  # synchronously.
64
162
  class PassThroughSmoother
65
- # @rbs @io: untyped
163
+ # @rbs @io: IO
66
164
 
67
- # @rbs io: untyped
165
+ # @rbs io: IO
68
166
  # @rbs return: void
69
167
  def initialize(io) = @io = io
70
168
 
@@ -88,25 +186,6 @@ class Riffer::Rig::UI::Renderer
88
186
  smoother.drain
89
187
  end
90
188
 
91
- # @rbs usage: Riffer::Providers::TokenUsage
92
- # @rbs return: void
93
- def render_token_usage(usage)
94
- tally = @tally
95
- return unless tally
96
-
97
- tally.add(usage)
98
-
99
- parts = ["↑#{usage.input_tokens}", "↓#{usage.output_tokens}"]
100
- parts << "cache_write:#{usage.cache_write_tokens}" if usage.cache_write_tokens&.positive?
101
- parts << "cache_read:#{usage.cache_read_tokens}" if usage.cache_read_tokens&.positive?
102
-
103
- session_parts = ["session #{tally.total_tokens} tok"]
104
- cost = tally.estimated_cost
105
- session_parts << format('~$%.4f', cost) if cost
106
-
107
- @io.puts("\n#{@theme.dim("#{parts.join(' · ')} #{session_parts.join(' · ')}")}")
108
- end
109
-
110
189
  # @rbs arguments: String
111
190
  # @rbs return: String
112
191
  def format_arguments(arguments)