riffer-rig 0.2.1 → 0.4.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +2 -2
  4. data/lib/riffer/rig/cli.rb +31 -2
  5. data/lib/riffer/rig/coding_agent.rb +35 -31
  6. data/lib/riffer/rig/credentials.rb +24 -4
  7. data/lib/riffer/rig/repl.rb +75 -22
  8. data/lib/riffer/rig/settings/pricing.rb +19 -0
  9. data/lib/riffer/rig/settings.rb +33 -8
  10. data/lib/riffer/rig/token_tally.rb +22 -6
  11. data/lib/riffer/rig/tools/bash.rb +23 -5
  12. data/lib/riffer/rig/tools/edit.rb +16 -2
  13. data/lib/riffer/rig/tools/read.rb +6 -1
  14. data/lib/riffer/rig/tools/write.rb +4 -0
  15. data/lib/riffer/rig/ui/animator.rb +51 -11
  16. data/lib/riffer/rig/ui/banner.rb +28 -4
  17. data/lib/riffer/rig/ui/cursor.rb +14 -2
  18. data/lib/riffer/rig/ui/palette.rb +13 -7
  19. data/lib/riffer/rig/ui/renderer.rb +137 -29
  20. data/lib/riffer/rig/ui/smoother.rb +56 -14
  21. data/lib/riffer/rig/ui/theme.rb +38 -7
  22. data/lib/riffer/rig/version.rb +1 -1
  23. data/sig/generated/riffer/rig/cli.rbs +41 -0
  24. data/sig/generated/riffer/rig/coding_agent.rbs +9 -0
  25. data/sig/generated/riffer/rig/credentials.rbs +55 -0
  26. data/sig/generated/riffer/rig/repl.rbs +68 -0
  27. data/sig/generated/riffer/rig/settings/pricing.rbs +23 -0
  28. data/sig/generated/riffer/rig/settings.rbs +92 -0
  29. data/sig/generated/riffer/rig/token_tally.rbs +61 -0
  30. data/sig/generated/riffer/rig/tools/bash.rbs +28 -0
  31. data/sig/generated/riffer/rig/tools/edit.rbs +11 -0
  32. data/sig/generated/riffer/rig/tools/read.rbs +12 -0
  33. data/sig/generated/riffer/rig/tools/write.rbs +9 -0
  34. data/sig/generated/riffer/rig/ui/animator.rbs +76 -0
  35. data/sig/generated/riffer/rig/ui/banner.rbs +42 -0
  36. data/sig/generated/riffer/rig/ui/cursor.rbs +33 -0
  37. data/sig/generated/riffer/rig/ui/palette.rbs +17 -0
  38. data/sig/generated/riffer/rig/ui/renderer.rbs +116 -0
  39. data/sig/generated/riffer/rig/ui/smoother.rbs +72 -0
  40. data/sig/generated/riffer/rig/ui/theme.rbs +60 -0
  41. data/sig/generated/riffer/rig/version.rbs +7 -0
  42. data/sig/generated/riffer/rig.rbs +2 -0
  43. data/sig/manual/riffer/rig/cli.rbs +8 -0
  44. data/sig/manual/riffer/rig/credentials.rbs +8 -0
  45. data/sig/manual/riffer/rig/settings.rbs +8 -0
  46. data/sig/manual/riffer/rig/tools.rbs +5 -0
  47. data/sig/manual/riffer/rig/ui/banner.rbs +8 -0
  48. data/sig/manual/riffer/rig/ui.rbs +5 -0
  49. metadata +31 -4
@@ -9,14 +9,22 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
9
9
 
10
10
  timeout 600
11
11
 
12
- DEFAULT_TIMEOUT_MS = 120_000
13
- MAX_OUTPUT_BYTES = 30_000
12
+ DEFAULT_TIMEOUT_MS = 120_000 #: Integer
13
+
14
+ MAX_OUTPUT_BYTES = 30_000 #: Integer
14
15
 
15
16
  params do
16
17
  required :command, String, description: 'The shell command to run'
17
- 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
18
22
  end
19
23
 
24
+ # @rbs context: Riffer::Agent::Context?
25
+ # @rbs command: String
26
+ # @rbs ?timeout_ms: Integer
27
+ # @rbs return: Riffer::Tools::Response
20
28
  def call(context:, command:, timeout_ms: DEFAULT_TIMEOUT_MS)
21
29
  output, status = run(command, timeout_ms / 1000.0)
22
30
  output = truncate(output.rstrip)
@@ -28,11 +36,16 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
28
36
 
29
37
  private
30
38
 
39
+ # @rbs command: String
40
+ # @rbs timeout_seconds: Float
41
+ # @rbs return: [String, Integer]
31
42
  def run(command, timeout_seconds)
32
43
  stdin, stdout_and_stderr, wait_thread = Open3.popen2e(command, chdir: Dir.pwd, pgroup: true)
33
44
  stdin.close
34
45
 
35
- unless wait_thread.join(timeout_seconds)
46
+ joined = wait_thread.join(timeout_seconds) #: Thread?
47
+
48
+ unless joined
36
49
  kill_group(wait_thread.pid)
37
50
  output = stdout_and_stderr.read
38
51
  stdout_and_stderr.close
@@ -44,12 +57,17 @@ class Riffer::Rig::Tools::Bash < Riffer::Tool
44
57
  [output, wait_thread.value.exitstatus || 1]
45
58
  end
46
59
 
60
+ # @rbs pid: Integer
61
+ # @rbs return: Integer
47
62
  def kill_group(pid)
48
63
  Process.kill('TERM', -Process.getpgid(pid))
64
+ Process.getpgid(pid)
49
65
  rescue Errno::ESRCH, Errno::EPERM
50
- nil
66
+ 0
51
67
  end
52
68
 
69
+ # @rbs output: String
70
+ # @rbs return: String
53
71
  def truncate(output)
54
72
  return output if output.bytesize <= MAX_OUTPUT_BYTES
55
73
 
@@ -9,9 +9,18 @@ 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
 
18
+ # @rbs context: Riffer::Agent::Context?
19
+ # @rbs path: String
20
+ # @rbs old_string: String
21
+ # @rbs new_string: String
22
+ # @rbs ?replace_all: bool
23
+ # @rbs return: Riffer::Tools::Response
15
24
  def call(context:, path:, old_string:, new_string:, replace_all: false)
16
25
  resolved = File.expand_path(path, Dir.pwd)
17
26
  return error("File not found: #{path}", type: :not_found) unless File.file?(resolved)
@@ -20,7 +29,12 @@ class Riffer::Rig::Tools::Edit < Riffer::Tool
20
29
  occurrences = content.scan(old_string).length
21
30
 
22
31
  return error("old_string not found in #{path}", type: :not_found) if occurrences.zero?
23
- 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
24
38
 
25
39
  # Block form so backslash sequences in new_string (\0, \1, \\) are inserted
26
40
  # literally instead of being interpreted as regexp backreferences.
@@ -5,7 +5,7 @@ class Riffer::Rig::Tools::Read < Riffer::Tool
5
5
  description 'Read a file from the filesystem. Returns its contents with line numbers. ' \
6
6
  'Use offset/limit to read a slice of a large file.'
7
7
 
8
- DEFAULT_LIMIT = 2000
8
+ DEFAULT_LIMIT = 2000 #: Integer
9
9
 
10
10
  params do
11
11
  required :path, String, description: 'Path to the file (absolute, or relative to the working directory)'
@@ -13,6 +13,11 @@ class Riffer::Rig::Tools::Read < Riffer::Tool
13
13
  optional :limit, Integer, description: 'Maximum number of lines to read', default: DEFAULT_LIMIT
14
14
  end
15
15
 
16
+ # @rbs context: Riffer::Agent::Context?
17
+ # @rbs path: String
18
+ # @rbs ?offset: Integer?
19
+ # @rbs ?limit: Integer
20
+ # @rbs return: Riffer::Tools::Response
16
21
  def call(context:, path:, offset: nil, limit: DEFAULT_LIMIT)
17
22
  resolved = File.expand_path(path, Dir.pwd)
18
23
  return error("File not found: #{path}", type: :not_found) unless File.file?(resolved)
@@ -11,6 +11,10 @@ class Riffer::Rig::Tools::Write < Riffer::Tool
11
11
  required :content, String, description: 'The full content to write to the file'
12
12
  end
13
13
 
14
+ # @rbs context: Riffer::Agent::Context?
15
+ # @rbs path: String
16
+ # @rbs content: String
17
+ # @rbs return: Riffer::Tools::Response
14
18
  def call(context:, path:, content:)
15
19
  resolved = File.expand_path(path, Dir.pwd)
16
20
  FileUtils.mkdir_p(File.dirname(resolved))
@@ -3,20 +3,36 @@
3
3
  # Animated output is gated behind an interactive, colour-enabled TTY, so piped
4
4
  # or tested runs stay silent and escape-free.
5
5
  class Riffer::Rig::UI::Animator
6
- REVEAL_FRAME_SECONDS = 0.05
7
- SPINNER_FRAME_SECONDS = 0.12
8
- EQ_LEVELS = '▁▂▃▄▅▆▇█'.chars.freeze
9
- EQ_BARS = 7
10
- NEUTRAL_LABEL = 'riffing…'
6
+ REVEAL_FRAME_SECONDS = 0.05 #: Float
7
+
8
+ SPINNER_FRAME_SECONDS = 0.12 #: Float
9
+
10
+ EQ_LEVELS = '▁▂▃▄▅▆▇█'.chars.freeze #: Array[String]
11
+
12
+ EQ_BARS = 7 #: Integer
13
+
14
+ NEUTRAL_LABEL = 'riffing…' #: String
15
+
11
16
  REASONING_PHRASES = [
12
17
  'contemplating…', 'pondering…', 'mulling it over…', 'reasoning…',
13
18
  'connecting the dots…', 'herding thoughts…', 'consulting the muse…',
14
19
  'doing some deep listening…', 'warming up…', 'tuning up…',
15
20
  'in the woodshed…', 'vamping…', 'finding the key…', 'counting it off…',
16
21
  'jamming internally…'
17
- ].freeze
18
- REASONING_TICK_RANGE = (1..5)
22
+ ].freeze #: Array[String]
19
23
 
24
+ REASONING_TICK_RANGE = (1..5) #: Range[Integer]
25
+
26
+ # @rbs @io: untyped
27
+ # @rbs @theme: Riffer::Rig::UI::Theme
28
+ # @rbs @thread: Thread?
29
+ # @rbs @mode: Symbol
30
+ # @rbs @phrase: String?
31
+ # @rbs @stop: bool
32
+
33
+ # @rbs io: untyped
34
+ # @rbs ?theme: Riffer::Rig::UI::Theme
35
+ # @rbs return: void
20
36
  def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io))
21
37
  @io = io
22
38
  @theme = theme
@@ -26,6 +42,9 @@ class Riffer::Rig::UI::Animator
26
42
  end
27
43
 
28
44
  # When not on a TTY, prints the final frame once instead of animating.
45
+ #
46
+ # @rbs frames: Array[Array[String]]
47
+ # @rbs return: void
29
48
  def reveal(frames)
30
49
  unless enabled?
31
50
  frames.last.each { |line| @io.puts(line) }
@@ -41,6 +60,8 @@ class Riffer::Rig::UI::Animator
41
60
  end
42
61
  end
43
62
 
63
+ # @rbs ?mode: Symbol
64
+ # @rbs return: void
44
65
  def start(mode = :neutral)
45
66
  return unless enabled?
46
67
 
@@ -54,16 +75,21 @@ class Riffer::Rig::UI::Animator
54
75
  @thread = Thread.new { animate }
55
76
  end
56
77
 
78
+ # @rbs return: void
57
79
  def stop
58
- return unless @thread
80
+ thread = @thread
81
+ return unless thread
59
82
 
60
83
  @stop = true
61
- @thread.join
84
+ thread.join
62
85
  @thread = nil
63
86
  @io.print("\r\e[K")
64
87
  @io.flush
65
88
  end
66
89
 
90
+ # @rbs tick: Integer
91
+ # @rbs ?label: String
92
+ # @rbs return: String
67
93
  def equalizer(tick, label = NEUTRAL_LABEL)
68
94
  bars = Array.new(EQ_BARS) do |i|
69
95
  height = (Math.sin((tick + i) * 0.6).abs * (EQ_LEVELS.length - 1)).round
@@ -75,6 +101,7 @@ class Riffer::Rig::UI::Animator
75
101
 
76
102
  private
77
103
 
104
+ # @rbs return: void
78
105
  def animate
79
106
  tick = 0
80
107
  roll_at = 0.0 # force an immediate phrase roll when entering reasoning mode
@@ -88,18 +115,31 @@ class Riffer::Rig::UI::Animator
88
115
  end
89
116
 
90
117
  # Re-rolls both phrase and duration whenever the reasoning tick expires.
118
+ #
119
+ # @rbs roll_at: Float
120
+ # @rbs return: Float
91
121
  def roll_phrase(roll_at)
92
122
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
123
  return roll_at unless @mode == :reasoning && now >= roll_at
94
124
 
95
125
  @phrase = REASONING_PHRASES.sample
96
- now + rand(REASONING_TICK_RANGE)
126
+ now + roll_rand
127
+ end
128
+
129
+ # Range rand returns nil for an empty range; this one is a non-empty constant.
130
+ #
131
+ # @rbs return: Integer
132
+ def roll_rand
133
+ x = rand(REASONING_TICK_RANGE)
134
+ x || 0
97
135
  end
98
136
 
137
+ # @rbs return: String
99
138
  def label
100
- @mode == :reasoning ? @phrase : NEUTRAL_LABEL
139
+ @mode == :reasoning ? @phrase || NEUTRAL_LABEL : NEUTRAL_LABEL
101
140
  end
102
141
 
142
+ # @rbs return: bool
103
143
  def enabled?
104
144
  @theme.enabled && @io.respond_to?(:tty?) && @io.tty?
105
145
  end
@@ -10,7 +10,7 @@ module Riffer::Rig::UI::Banner
10
10
  '██╔══██╗██║██╔══╝ ██╔══╝ ██╔══╝ ██╔══██╗',
11
11
  '██║ ██║██║██║ ██║ ███████╗██║ ██║',
12
12
  '╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝'
13
- ].freeze
13
+ ].freeze #: Array[String]
14
14
 
15
15
  ROW_COLOURS = [
16
16
  Riffer::Rig::UI::Palette::PINK,
@@ -19,25 +19,49 @@ module Riffer::Rig::UI::Banner
19
19
  Riffer::Rig::UI::Palette::BLUE,
20
20
  Riffer::Rig::UI::Palette::CYAN,
21
21
  Riffer::Rig::UI::Palette::CYAN
22
- ].freeze
22
+ ].freeze #: Array[Array[Integer]]
23
23
 
24
- INFO_LABEL_WIDTH = 8
25
- INDENT = ' '
24
+ INFO_LABEL_WIDTH = 8 #: Integer
26
25
 
26
+ INDENT = ' ' #: String
27
+
28
+ # @rbs theme: Riffer::Rig::UI::Theme
29
+ # @rbs model: String
30
+ # @rbs cwd: String
31
+ # @rbs context: String
32
+ # @rbs skills: String
33
+ # @rbs version: String
34
+ # @rbs return: String
27
35
  def call(theme, model:, cwd:, context:, skills:, version:)
28
36
  lines(theme, model: model, cwd: cwd, context: context, skills: skills, version: version).join("\n")
29
37
  end
30
38
 
39
+ # @rbs theme: Riffer::Rig::UI::Theme
40
+ # @rbs model: String
41
+ # @rbs cwd: String
42
+ # @rbs context: String
43
+ # @rbs skills: String
44
+ # @rbs version: String
45
+ # @rbs return: Array[String]
31
46
  def lines(theme, model:, cwd:, context:, skills:, version:)
32
47
  [''] + art(theme) + [''] +
33
48
  info(theme, model: model, cwd: cwd, context: context, skills: skills, version: version) + ['']
34
49
  end
35
50
 
51
+ # @rbs theme: Riffer::Rig::UI::Theme
52
+ # @rbs return: Array[String]
36
53
  def art(theme)
37
54
  art = WORDMARK.each_index.map { |i| "#{INDENT}#{theme.paint(WORDMARK[i], ROW_COLOURS[i])}" }
38
55
  art + ["#{INDENT}#{theme.cyan("♪ let's riff ♪")}"]
39
56
  end
40
57
 
58
+ # @rbs theme: Riffer::Rig::UI::Theme
59
+ # @rbs model: String
60
+ # @rbs cwd: String
61
+ # @rbs context: String
62
+ # @rbs skills: String
63
+ # @rbs version: String
64
+ # @rbs return: Array[String]
41
65
  def info(theme, model:, cwd:, context:, skills:, version:)
42
66
  rows = { model: model, cwd: cwd, context: context, skills: skills, version: version }.map do |label, value|
43
67
  " #{theme.magenta('▸')} #{theme.grey(label.to_s.ljust(INFO_LABEL_WIDTH))}#{value}"
@@ -3,24 +3,35 @@
3
3
  # Hides the terminal cursor while a turn is in flight: a hidden cursor can't
4
4
  # flicker against the animator's erase-and-redraw churn.
5
5
  class Riffer::Rig::UI::Cursor
6
- HIDE = "\e[?25l"
7
- SHOW = "\e[?25h"
6
+ HIDE = "\e[?25l" #: String
8
7
 
8
+ SHOW = "\e[?25h" #: String
9
+
10
+ # @rbs @io: untyped
11
+ # @rbs @theme: Riffer::Rig::UI::Theme
12
+
13
+ # @rbs io: untyped
14
+ # @rbs ?theme: Riffer::Rig::UI::Theme
15
+ # @rbs return: void
9
16
  def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io))
10
17
  @io = io
11
18
  @theme = theme
12
19
  end
13
20
 
21
+ # @rbs return: void
14
22
  def hide
15
23
  write(HIDE)
16
24
  end
17
25
 
26
+ # @rbs return: void
18
27
  def show
19
28
  write(SHOW)
20
29
  end
21
30
 
22
31
  private
23
32
 
33
+ # @rbs sequence: String
34
+ # @rbs return: void
24
35
  def write(sequence)
25
36
  return unless enabled?
26
37
 
@@ -28,6 +39,7 @@ class Riffer::Rig::UI::Cursor
28
39
  @io.flush
29
40
  end
30
41
 
42
+ # @rbs return: bool
31
43
  def enabled?
32
44
  @theme.enabled && @io.respond_to?(:tty?) && @io.tty?
33
45
  end
@@ -1,11 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Riffer::Rig::UI::Palette
4
- PINK = [255, 106, 193].freeze
5
- MAGENTA = [192, 76, 253].freeze
6
- PURPLE = [122, 92, 255].freeze
7
- BLUE = [77, 124, 255].freeze
8
- CYAN = [45, 226, 230].freeze
9
- GREY = [138, 139, 154].freeze
10
- RED = [255, 92, 122].freeze
4
+ PINK = [255, 106, 193].freeze #: Array[Integer]
5
+
6
+ MAGENTA = [192, 76, 253].freeze #: Array[Integer]
7
+
8
+ PURPLE = [122, 92, 255].freeze #: Array[Integer]
9
+
10
+ BLUE = [77, 124, 255].freeze #: Array[Integer]
11
+
12
+ CYAN = [45, 226, 230].freeze #: Array[Integer]
13
+
14
+ GREY = [138, 139, 154].freeze #: Array[Integer]
15
+
16
+ RED = [255, 92, 122].freeze #: Array[Integer]
11
17
  end
@@ -3,83 +3,189 @@
3
3
  require 'json'
4
4
 
5
5
  class Riffer::Rig::UI::Renderer
6
- RESULT_PREVIEW_LIMIT = 200
7
-
6
+ RESULT_PREVIEW_LIMIT = 200 #: Integer
7
+
8
+ # @rbs @io: untyped
9
+ # @rbs @theme: Riffer::Rig::UI::Theme
10
+ # @rbs @tally: Riffer::Rig::TokenTally?
11
+ # @rbs @smoother: Riffer::Rig::UI::Smoother | PassThroughSmoother?
12
+ # @rbs @deferred_usage: Riffer::Providers::TokenUsage?
13
+ # @rbs @prose_gap_pending: bool
14
+
15
+ # @rbs io: untyped
16
+ # @rbs ?theme: Riffer::Rig::UI::Theme
17
+ # @rbs ?tally: Riffer::Rig::TokenTally?
18
+ # @rbs ?smoother: Riffer::Rig::UI::Smoother | PassThroughSmoother?
19
+ # @rbs return: void
8
20
  def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io), tally: nil, smoother: nil)
9
21
  @io = io
10
22
  @theme = theme
11
23
  @tally = tally
12
24
  @smoother = smoother
25
+ @deferred_usage = nil
26
+ @prose_gap_pending = false
13
27
  end
14
28
 
29
+ # @rbs event: Riffer::StreamEvents::Base
30
+ # @rbs return: void
15
31
  def render(event)
16
32
  case event
17
33
  when Riffer::StreamEvents::TextDelta
18
- smoother << event.content
34
+ render_prose(event.content)
19
35
  when Riffer::StreamEvents::ToolCallDone
20
- drain_smoother
21
- @io.puts("\n#{@theme.cyan("⚙ #{event.name}(#{format_arguments(event.arguments)})")}")
36
+ render_tool_activity(2) { @theme.cyan("⚙ #{event.name}(#{format_arguments(event.arguments)})") }
22
37
  when Riffer::StreamEvents::SkillActivation
23
- drain_smoother
24
- @io.puts("\n#{@theme.magenta("✦ skill: #{event.name}")}")
38
+ render_block(0) { @theme.magenta("✦ skill: #{event.name}") }
25
39
  when Riffer::StreamEvents::Interrupt
26
- drain_smoother
27
- @io.puts(@theme.dim("[interrupted: #{event.reason}]"))
40
+ render_block(0) { @theme.dim("[interrupted: #{event.reason}]") }
28
41
  when Riffer::StreamEvents::TokenUsageDone
29
- drain_smoother
30
- render_token_usage(event.token_usage)
42
+ current = @deferred_usage
43
+ @deferred_usage = current ? current + event.token_usage : event.token_usage
31
44
  end
32
45
  end
33
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
+
75
+ # @rbs message: Riffer::Messages::Base
76
+ # @rbs return: void
34
77
  def render_tool_result(message)
35
78
  return unless message.is_a?(Riffer::Messages::Tool)
36
79
 
80
+ open_tool_activity
37
81
  line = "↳ #{preview(message.content)}"
38
- @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
39
85
  end
40
86
 
41
87
  private
42
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 return: void
96
+ def render_block(indent, &)
97
+ drain_smoother
98
+ @io.puts
99
+ @prose_gap_pending = true
100
+ @io.puts((' ' * indent) + yield)
101
+ @io.flush
102
+ end
103
+
104
+ # The blank line above a prose block is written at the first delta after a
105
+ # non-prose block. Streaming makes "which block is first?" a stateful
106
+ # question, so the answer is tracked rather than embedded at each render site.
107
+ #
108
+ # @rbs content: String
109
+ # @rbs return: void
110
+ def render_prose(content)
111
+ if @prose_gap_pending
112
+ @prose_gap_pending = false
113
+ @io.print("\n")
114
+ end
115
+ smoother << content
116
+ end
117
+
118
+ # @rbs indent: Integer
119
+ # @rbs return: void
120
+ def render_tool_activity(indent, &)
121
+ open_tool_activity
122
+ @io.puts((' ' * indent) + yield)
123
+ @io.flush
124
+ end
125
+
126
+ # Tool-activity lines (⚙ calls, ↳ results) share one blank line above the
127
+ # group instead of one between each line — the gap goes between groups, not
128
+ # inside a pair. The group stays open so prose after it pays the closing gap.
129
+ #
130
+ # @rbs return: void
131
+ def open_tool_activity
132
+ return if @prose_gap_pending
133
+
134
+ drain_smoother
135
+ @io.puts
136
+ @prose_gap_pending = true
137
+ end
138
+
139
+ # @rbs usage: Riffer::Providers::TokenUsage
140
+ # @rbs tally: Riffer::Rig::TokenTally
141
+ # @rbs return: String
142
+ def usage_line(usage, tally)
143
+ parts = ["↑#{usage.input_tokens}", "↓#{usage.output_tokens}"]
144
+ parts << "cache_write:#{usage.cache_write_tokens}" if usage.cache_write_tokens&.positive?
145
+ parts << "cache_read:#{usage.cache_read_tokens}" if usage.cache_read_tokens&.positive?
146
+ parts << "session #{tally.total_tokens} tok"
147
+ cost = tally.estimated_cost
148
+ parts << format('~$%.4f', cost) if cost
149
+
150
+ parts.join(' · ')
151
+ end
152
+
153
+ # @rbs return: Riffer::Rig::UI::Smoother | PassThroughSmoother
43
154
  def smoother
44
155
  @smoother || PassThroughSmoother.new(@io)
45
156
  end
46
157
 
47
- # Stand-in when no smoother is injected, so a bare Renderer still prints
158
+ # Stand-in when no smoother is injected, so a bare Riffer::Rig::UI::Renderer still prints
48
159
  # synchronously.
49
160
  class PassThroughSmoother
161
+ # @rbs @io: untyped
162
+
163
+ # @rbs io: untyped
164
+ # @rbs return: void
50
165
  def initialize(io) = @io = io
51
166
 
167
+ # @rbs content: String
168
+ # @rbs return: self
52
169
  def <<(content)
53
170
  @io.print(content)
54
171
  @io.flush
55
172
  self
56
173
  end
57
174
 
175
+ # @rbs return: nil
58
176
  def drain = nil
59
177
 
178
+ # @rbs return: nil
60
179
  def finish = nil
61
180
  end
62
181
 
182
+ # @rbs return: void
63
183
  def drain_smoother
64
184
  smoother.drain
65
185
  end
66
186
 
67
- def render_token_usage(usage)
68
- return unless @tally
69
-
70
- @tally.add(usage)
71
-
72
- parts = ["↑#{usage.input_tokens}", "↓#{usage.output_tokens}"]
73
- parts << "cache_write:#{usage.cache_write_tokens}" if usage.cache_write_tokens&.positive?
74
- parts << "cache_read:#{usage.cache_read_tokens}" if usage.cache_read_tokens&.positive?
75
-
76
- session_parts = ["session #{@tally.total_tokens} tok"]
77
- cost = @tally.estimated_cost
78
- session_parts << format('~$%.4f', cost) if cost
79
-
80
- @io.puts("\n#{@theme.dim("#{parts.join(' · ')} #{session_parts.join(' · ')}")}")
81
- end
82
-
187
+ # @rbs arguments: String
188
+ # @rbs return: String
83
189
  def format_arguments(arguments)
84
190
  parsed = JSON.parse(arguments)
85
191
  parsed.map { |key, value| "#{key}: #{value.inspect}" }.join(', ')
@@ -87,6 +193,8 @@ class Riffer::Rig::UI::Renderer
87
193
  arguments
88
194
  end
89
195
 
196
+ # @rbs content: String
197
+ # @rbs return: String
90
198
  def preview(content)
91
199
  first_line = content.to_s.lines.first.to_s.chomp
92
200
  return first_line if first_line.length <= RESULT_PREVIEW_LIMIT