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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01f34d2a4cb9347ea2d9f7a81a0969415c98b6622d4f78e123034f9b2c15509e
4
- data.tar.gz: 5a61c231fcf2832a514ec62513bdf42e56c87dc08ec994f9fb04fa263188b53d
3
+ metadata.gz: 4a1580a7e4f2ed5e6d1f18714ef469b7d4623bec94552510a836a5f5e5664504
4
+ data.tar.gz: 7e871b6b1f8c9de6e3788979103f7afe65beea7a9c5fa7813e17b29e4e8eb528
5
5
  SHA512:
6
- metadata.gz: 1b8518928f77909373cfdfe2783372aa1a6ebb04fed67a70951041c0adf637bf3aba0ee8b85051f9bdb63c1cdcda91554b2fd9c86b4f82a279eed30964984588
7
- data.tar.gz: 3cd5548405bcf8607d516e7f8a585cdf327cfc904e1641458aca4802e8a3654127c6ed44a46435003faeca1eaaafcd4bed0968c0787a90aca8c586438b685146
6
+ metadata.gz: db021260a12a48c02bb6ff92278819416bcc267bd78015f01f80bd3a9ed64c585e37d17573a3ba47ba28d99bd0ef586665a465782d36a6a85231c08ad4a4fd51
7
+ data.tar.gz: 5ea0ed0a06d1a318267bd3d63b693f974ff8ca814e26be0eb5837cdac09527bea33a75357f7a1a11b419854ed72810a5d571c8508010e8bd62f327eb922f04eb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/bottrall/riffer-rig/compare/v0.4.0...v0.5.0) (2026-09-19)
4
+
5
+
6
+ ### Features
7
+
8
+ * add ask, max_steps and riffer response ([#178](https://github.com/bottrall/riffer-rig/issues/178)) ([948d753](https://github.com/bottrall/riffer-rig/commit/948d75354a7d0a5a9d35055b7d890dea9e51f1a1))
9
+ * add Runtime with a per-instance agent, base prompt and tool seam ([#160](https://github.com/bottrall/riffer-rig/issues/160)) ([97019ca](https://github.com/bottrall/riffer-rig/commit/97019ca6f80c95bcf22e443652f1d90d453d02de))
10
+ * type IO streams and JSON boundaries, dropping explicit untyped ([#174](https://github.com/bottrall/riffer-rig/issues/174)) ([22407be](https://github.com/bottrall/riffer-rig/commit/22407befc471d1fbbe14188456ee1ce248ddc4c7))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * drain smoother backlog before every animator restart ([#177](https://github.com/bottrall/riffer-rig/issues/177)) ([3126155](https://github.com/bottrall/riffer-rig/commit/3126155fdeba3de2ab2b9905b7e7de55593de834))
16
+ * drop invalid ? optional markers from rbs-inline annotations ([#171](https://github.com/bottrall/riffer-rig/issues/171)) ([60030bf](https://github.com/bottrall/riffer-rig/commit/60030bf7ca930e9d35cbd8c2e3a8ae8ae1692830))
17
+
18
+ ## [0.4.0](https://github.com/bottrall/riffer-rig/compare/v0.3.0...v0.4.0) (2026-09-13)
19
+
20
+
21
+ ### Features
22
+
23
+ * **ui:** one blank line between blocks, prompt-owned gaps ([#170](https://github.com/bottrall/riffer-rig/issues/170)) ([636e4d8](https://github.com/bottrall/riffer-rig/commit/636e4d82a069f158775043037a43e2aa494f56dc))
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * **lint:** adjust rubocop rules and reformat to match ([#168](https://github.com/bottrall/riffer-rig/issues/168)) ([56b81aa](https://github.com/bottrall/riffer-rig/commit/56b81aa904609f246340c8b8135ee7e57791abdd))
29
+
3
30
  ## [0.3.0](https://github.com/bottrall/riffer-rig/compare/v0.2.1...v0.3.0) (2026-09-10)
4
31
 
5
32
 
@@ -12,8 +12,8 @@ module Riffer::Rig::CLI
12
12
  'openrouter' => 'https://openrouter.ai/keys'
13
13
  }.freeze #: Hash[String, String]
14
14
 
15
- # @rbs output: untyped
16
- # @rbs input: untyped
15
+ # @rbs output: IO
16
+ # @rbs input: IO
17
17
  # @rbs return: Integer
18
18
  def start(output: $stdout, input: $stdin)
19
19
  theme = Riffer::Rig::UI::Theme.for(output)
@@ -21,8 +21,7 @@ module Riffer::Rig::CLI
21
21
  model = Riffer::Rig::Settings.model
22
22
  provider = Riffer::Rig::Settings.provider_for(model)
23
23
 
24
- api_key = (provider && Riffer::Rig::Credentials.api_key_for(provider)) ||
25
- onboard(provider, theme, output:, input:)
24
+ api_key = (provider && Riffer::Rig::Credentials.api_key_for(provider)) || onboard(provider, theme, output:, input:)
26
25
  return 1 if api_key.nil?
27
26
 
28
27
  configure_provider(provider, api_key)
@@ -55,8 +54,8 @@ module Riffer::Rig::CLI
55
54
 
56
55
  # @rbs provider: String?
57
56
  # @rbs theme: Riffer::Rig::UI::Theme
58
- # @rbs output: untyped
59
- # @rbs input: untyped
57
+ # @rbs output: IO
58
+ # @rbs input: IO
60
59
  # @rbs return: String?
61
60
  def onboard(provider, theme, output:, input:)
62
61
  url = (provider && PROVIDER_URLS[provider]) || 'your provider'
@@ -85,7 +84,7 @@ module Riffer::Rig::CLI
85
84
  (provider && Riffer::Rig::Credentials::PROVIDER_ENV_VARS[provider]) || 'the appropriate API key env var'
86
85
  end
87
86
 
88
- # @rbs input: untyped
87
+ # @rbs input: IO
89
88
  # @rbs return: String?
90
89
  def read_secret(input)
91
90
  return input.noecho(&:gets) if input.respond_to?(:noecho) && input.tty?
@@ -101,7 +100,16 @@ module Riffer::Rig::CLI
101
100
  loaded = [Riffer::Rig::CodingAgent::GLOBAL_AGENTS_FILE, File.join(Dir.pwd, 'AGENTS.md')].select { |path| File.file?(path) }
102
101
  context = loaded.empty? ? 'none' : loaded.join(', ')
103
102
 
104
- animator.reveal([Riffer::Rig::UI::Banner.lines(theme, model: model, cwd: Dir.pwd, context: context, skills: count_skills, version: Riffer::Rig::VERSION)])
103
+ animator.reveal(
104
+ [Riffer::Rig::UI::Banner.lines(
105
+ theme,
106
+ model: model,
107
+ cwd: Dir.pwd,
108
+ context: context,
109
+ skills: count_skills,
110
+ version: Riffer::Rig::VERSION
111
+ )]
112
+ )
105
113
  end
106
114
 
107
115
  # @rbs return: String
@@ -12,12 +12,7 @@ class Riffer::Rig::CodingAgent < Riffer::Agent
12
12
  model Riffer::Rig::Settings.model
13
13
  model_options Riffer::Rig::Settings.model_options
14
14
 
15
- uses_tools [
16
- Riffer::Rig::Tools::Read,
17
- Riffer::Rig::Tools::Write,
18
- Riffer::Rig::Tools::Edit,
19
- Riffer::Rig::Tools::Bash
20
- ]
15
+ uses_tools [Riffer::Rig::Tools::Read, Riffer::Rig::Tools::Write, Riffer::Rig::Tools::Edit, Riffer::Rig::Tools::Bash]
21
16
 
22
17
  skills do
23
18
  backend(->(_ctx) { Riffer::Skills::FilesystemBackend.new(GLOBAL_SKILLS_DIR, PROJECT_SKILLS_DIR.call) })
@@ -25,32 +20,34 @@ class Riffer::Rig::CodingAgent < Riffer::Agent
25
20
 
26
21
  max_steps nil
27
22
 
28
- instructions(lambda do
29
- context_files = [GLOBAL_AGENTS_FILE, File.join(Dir.pwd, 'AGENTS.md')]
30
- .select { |path| File.file?(path) }
31
- .map { |path| "<project_instructions path=\"#{path}\">\n#{File.read(path)}\n</project_instructions>" }
32
-
33
- project_context =
34
- if context_files.empty?
35
- ''
36
- else
37
- "\n\n<project_context>\n#{context_files.join("\n\n")}\n</project_context>"
38
- end
39
-
40
- <<~PROMPT.chomp + project_context + "\n\nCurrent date: #{Date.today}\nCurrent working directory: #{Dir.pwd}"
41
- You are an expert coding assistant running inside riffer-rig, a terminal coding agent. You help the user by reading files, running shell commands, editing code, and writing new files.
42
-
43
- Available tools:
44
- - read: read a file's contents
45
- - write: write content to a file
46
- - edit: replace an exact string in a file
47
- - bash: run a shell command in the working directory
48
-
49
- Guidelines:
50
- - Be concise and direct in your responses.
51
- - Show file paths clearly when working with files.
52
- - Use bash for file exploration (ls, rg, find) rather than guessing.
53
- - Prefer editing existing files over creating new ones.
54
- PROMPT
55
- end)
23
+ instructions(
24
+ lambda do
25
+ context_files = [GLOBAL_AGENTS_FILE, File.join(Dir.pwd, 'AGENTS.md')]
26
+ .select { |path| File.file?(path) }
27
+ .map { |path| "<project_instructions path=\"#{path}\">\n#{File.read(path)}\n</project_instructions>" }
28
+
29
+ project_context =
30
+ if context_files.empty?
31
+ ''
32
+ else
33
+ "\n\n<project_context>\n#{context_files.join("\n\n")}\n</project_context>"
34
+ end
35
+
36
+ <<~PROMPT.chomp + project_context + "\n\nCurrent date: #{Date.today}\nCurrent working directory: #{Dir.pwd}"
37
+ You are an expert coding assistant running inside riffer-rig, a terminal coding agent. You help the user by reading files, running shell commands, editing code, and writing new files.
38
+
39
+ Available tools:
40
+ - read: read a file's contents
41
+ - write: write content to a file
42
+ - edit: replace an exact string in a file
43
+ - bash: run a shell command in the working directory
44
+
45
+ Guidelines:
46
+ - Be concise and direct in your responses.
47
+ - Show file paths clearly when working with files.
48
+ - Use bash for file exploration (ls, rg, find) rather than guessing.
49
+ - Prefer editing existing files over creating new ones.
50
+ PROMPT
51
+ end
52
+ )
56
53
  end
@@ -54,7 +54,7 @@ module Riffer::Rig::Credentials
54
54
  # @rbs return: String
55
55
  def save_api_key(provider, key, path: PATH)
56
56
  FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
57
- data = read(path).tap { |h| h[provider] = key }
57
+ data = read(path).merge(provider => key)
58
58
  File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
59
59
  file.write(JSON.pretty_generate(data))
60
60
  end
@@ -68,21 +68,21 @@ module Riffer::Rig::Credentials
68
68
  # @rbs return: String?
69
69
  def key_from_file(provider, path)
70
70
  key = read(path)[provider]
71
- return nil unless key.is_a?(String)
72
- return key unless key.strip.empty?
71
+ return nil if key.nil? || key.strip.empty?
73
72
 
74
- nil
73
+ key
75
74
  end
76
75
 
77
76
  # The parsed auth file, or an empty hash when the file is absent or
78
- # malformed.
77
+ # malformed. Non-string values can't be keys, so they're dropped here rather
78
+ # than re-checked by every reader.
79
79
  #
80
80
  # @rbs path: String
81
- # @rbs return: Hash[String, untyped]
81
+ # @rbs return: Hash[String, String]
82
82
  def read(path)
83
83
  return {} unless File.file?(path)
84
84
 
85
- JSON.parse(File.read(path))
85
+ JSON.parse(File.read(path)).select { |_provider, key| key.is_a?(String) }
86
86
  rescue JSON::ParserError
87
87
  {}
88
88
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # An extension recorded by <tt>Riffer::Rig.extension</tt>: a name, an optional
4
+ # Gem::Requirement on riffer-rig, and the block that runs against a Runtime's
5
+ # registrar. The process registry holds these objects; Runtime.new runs their
6
+ # blocks.
7
+ class Riffer::Rig::Extension
8
+ # @rbs @name: String
9
+ # @rbs @requires: Gem::Requirement?
10
+ # @rbs @block: ^(::Riffer::Rig::Registrar) -> void
11
+
12
+ # @dynamic name, requires
13
+ attr_reader :name #: String
14
+ attr_reader :requires #: Gem::Requirement?
15
+
16
+ # @rbs name: String
17
+ # @rbs requires: String?
18
+ # @rbs &block: (::Riffer::Rig::Registrar) -> void
19
+ # @rbs return: void
20
+ def initialize(name, requires: nil, &block)
21
+ @name = name
22
+ @requires = requires && Gem::Requirement.new(requires)
23
+ @block = block
24
+ end
25
+
26
+ # @rbs registrar: Riffer::Rig::Registrar
27
+ # @rbs return: void
28
+ def run(registrar)
29
+ @block.call(registrar)
30
+ end
31
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A host receives questions and out-of-band messages from a Runtime. It is
4
+ # duck-typed: any object with #ask, #confirm, #notify, #progress and
5
+ # #capabilities is a host.
6
+ #
7
+ # class HeadlessHost
8
+ # def capabilities = Set[:notify, :progress].freeze
9
+ # def ask(*) = nil
10
+ # def confirm(*) = false
11
+ # def notify(message, level:) = warn("[#{level}] #{message}")
12
+ # def progress(label) = yield
13
+ # end
14
+ #
15
+ # Callers check #capabilities before asking, so a host that declines a
16
+ # capability is never asked and the caller takes the declined path.
17
+ # The do-nothing host: the default +host:+ for a Runtime. Every capability is
18
+ # declined, so callers take the declined path without any UI attempt.
19
+ class Riffer::Rig::Host
20
+ # @rbs return: Set[Symbol]
21
+ def capabilities
22
+ Set.new.freeze
23
+ end
24
+
25
+ # @rbs _question: String?
26
+ # @rbs options: Array[String]?
27
+ # @rbs secret: bool
28
+ # @rbs return: String?
29
+ def ask(_question = nil, options: nil, secret: false)
30
+ nil
31
+ end
32
+
33
+ # @rbs _question: String?
34
+ # @rbs return: bool
35
+ def confirm(_question = nil)
36
+ false
37
+ end
38
+
39
+ # @rbs _message: String?
40
+ # @rbs level: Symbol
41
+ # @rbs return: void
42
+ def notify(_message = nil, level: :info)
43
+ nil
44
+ end
45
+
46
+ # @rbs _label: String?
47
+ # @rbs &block: ^() -> void
48
+ # @rbs return: void
49
+ def progress(_label = nil, &block)
50
+ block&.call
51
+ end
52
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The per-Runtime registrar extension blocks run against. Seams are added one
4
+ # at a time; today only #tool exists.
5
+ class Riffer::Rig::Registrar
6
+ # @rbs @tools: Array[singleton(Riffer::Tool)]
7
+
8
+ # @rbs return: void
9
+ def initialize
10
+ @tools = []
11
+ end
12
+
13
+ # @rbs klass: singleton(Riffer::Tool)
14
+ # @rbs return: void
15
+ def tool(klass)
16
+ @tools << klass
17
+ end
18
+
19
+ # @rbs return: Array[singleton(Riffer::Tool)]
20
+ def tools
21
+ @tools.dup
22
+ end
23
+ end
@@ -11,20 +11,28 @@ class Riffer::Rig::REPL
11
11
  # @rbs @smoother: Riffer::Rig::UI::Smoother
12
12
  # @rbs @cursor: Riffer::Rig::UI::Cursor
13
13
  # @rbs @theme: Riffer::Rig::UI::Theme
14
- # @rbs @input: untyped
15
- # @rbs @output: untyped
16
- # @rbs @pending_tool_calls: Array[Riffer::StreamEvents::ToolCallDone]
17
-
18
- # @rbs ?agent: Riffer::Agent
19
- # @rbs ?renderer: Riffer::Rig::UI::Renderer
20
- # @rbs input: untyped
21
- # @rbs output: untyped
22
- # @rbs ?theme: Riffer::Rig::UI::Theme
23
- # @rbs ?animator: Riffer::Rig::UI::Animator
24
- # @rbs ?smoother: Riffer::Rig::UI::Smoother
25
- # @rbs ?cursor: Riffer::Rig::UI::Cursor
14
+ # @rbs @input: IO
15
+ # @rbs @output: IO
16
+
17
+ # @rbs agent: Riffer::Agent
18
+ # @rbs renderer: Riffer::Rig::UI::Renderer
19
+ # @rbs input: IO
20
+ # @rbs output: IO
21
+ # @rbs theme: Riffer::Rig::UI::Theme
22
+ # @rbs animator: Riffer::Rig::UI::Animator
23
+ # @rbs smoother: Riffer::Rig::UI::Smoother
24
+ # @rbs cursor: Riffer::Rig::UI::Cursor
26
25
  # @rbs return: void
27
- def initialize(agent:, renderer:, input: $stdin, output: $stdout, theme: Riffer::Rig::UI::Theme.for(output), animator: Riffer::Rig::UI::Animator.new(io: output, theme:), smoother: Riffer::Rig::UI::Smoother.new(io: output, theme:), cursor: Riffer::Rig::UI::Cursor.new(io: output, theme:))
26
+ def initialize(
27
+ agent:,
28
+ renderer:,
29
+ input: $stdin,
30
+ output: $stdout,
31
+ theme: Riffer::Rig::UI::Theme.for(output),
32
+ animator: Riffer::Rig::UI::Animator.new(io: output, theme:),
33
+ smoother: Riffer::Rig::UI::Smoother.new(io: output, theme:),
34
+ cursor: Riffer::Rig::UI::Cursor.new(io: output, theme:)
35
+ )
28
36
  @agent = agent
29
37
  @renderer = renderer
30
38
  @animator = animator
@@ -33,14 +41,13 @@ class Riffer::Rig::REPL
33
41
  @theme = theme
34
42
  @input = input
35
43
  @output = output
36
- @pending_tool_calls = []
37
44
  @agent.session.on_message { |message| render_tool_result(message) }
38
45
  end
39
46
 
40
47
  # @rbs return: Symbol
41
48
  def run
42
49
  loop do
43
- @output.print("\n#{@theme.pink('›')} ")
50
+ @renderer.prompt
44
51
  line = @input.gets
45
52
  break if line.nil?
46
53
 
@@ -57,7 +64,7 @@ class Riffer::Rig::REPL
57
64
  end
58
65
  end
59
66
 
60
- @output.puts("\n#{@theme.grey('see you on the next riff.')}")
67
+ print_block { @theme.grey('see you on the next riff.') }
61
68
  :done
62
69
  end
63
70
 
@@ -67,49 +74,41 @@ class Riffer::Rig::REPL
67
74
  # @rbs return: void
68
75
  def run_turn(prompt)
69
76
  @cursor.hide
70
- @animator.start
77
+ start_animator
71
78
  @smoother.start
72
79
  @agent.stream(prompt).each do |event|
73
80
  case event
74
81
  when Riffer::StreamEvents::ReasoningDelta
75
- @animator.start(:reasoning)
82
+ start_animator(:reasoning)
76
83
  when Riffer::StreamEvents::ReasoningDone
77
- @animator.start
84
+ start_animator
78
85
  when Riffer::StreamEvents::ToolCallDelta, Riffer::StreamEvents::FinishReasonDone
79
- # Round bookkeeping: nothing renders, so the indicator just carries on
80
- # (or stays parked while the smoother finishes typing earlier text).
81
86
  next
82
- when Riffer::StreamEvents::ToolCallDone
83
- # Tool call lines are held back so the round's stats line can print
84
- # above them (see flush_pending_tool_calls).
85
- @pending_tool_calls << event
86
- next
87
- when Riffer::StreamEvents::SkillActivation, Riffer::StreamEvents::TokenUsageDone
88
- # The spinner shares its line with what's about to print, and tool
89
- # execution plus the next model invocation emit no events — stop it for
90
- # the render, then bring it straight back to cover the silent stretch.
91
- # Stats print before any pending tool call lines (see below).
87
+ when Riffer::StreamEvents::SkillActivation
88
+ # Tool execution and the next model invocation emit no events, so the
89
+ # spinner comes straight back on to cover the silent stretch.
92
90
  @animator.stop
93
91
  @renderer.render(event)
94
- flush_pending_tool_calls
95
- @animator.start
92
+ start_animator
93
+ next
94
+ when Riffer::StreamEvents::TokenUsageDone
95
+ # Usage renders nothing inline — it flushes below the turn's output.
96
+ # Restarting here covers the silent tool-execution stretch that follows.
97
+ @renderer.render(event)
98
+ start_animator
96
99
  next
97
100
  else
98
101
  @animator.stop
99
102
  end
100
- flush_pending_tool_calls
101
103
  @renderer.render(event)
102
104
  end
103
105
  @animator.stop
104
- flush_pending_tool_calls
105
106
  @smoother.finish
106
- @output.puts
107
+ @renderer.flush_usage
107
108
  rescue StandardError => e
108
- @animator.stop
109
- flush_pending_tool_calls
110
- @output.puts("\nError: #{e.message}")
111
- ensure
112
109
  @smoother.finish
110
+ print_block { @theme.red("Error: #{e.message}") }
111
+ ensure
113
112
  @animator.stop
114
113
  @cursor.show
115
114
  end
@@ -130,7 +129,7 @@ class Riffer::Rig::REPL
130
129
  skills = @agent.context.skills
131
130
 
132
131
  unless skills
133
- @output.puts(@theme.grey('No skills configured.'))
132
+ print_block { @theme.grey('No skills configured.') }
134
133
  return
135
134
  end
136
135
 
@@ -138,13 +137,13 @@ class Riffer::Rig::REPL
138
137
  # a non-mutating Context#read. `activate` marks the skill model-activated as
139
138
  # a side effect, which drops it from the model's catalog after manual use.
140
139
  body = skills.activate(name)
141
- @output.puts(@theme.magenta("✦ skill: #{name}"))
140
+ print_block { @theme.magenta("✦ skill: #{name}") }
142
141
  skill_block(name, body)
143
142
  rescue Riffer::ArgumentError
144
- @output.puts(@theme.red("Unknown skill: #{name}"))
143
+ print_block { @theme.red("Unknown skill: #{name}") }
145
144
  nil
146
145
  rescue StandardError => e
147
- @output.puts(@theme.red("Error activating skill: #{e.message}"))
146
+ print_block { @theme.red("Error activating skill: #{e.message}") }
148
147
  nil
149
148
  end
150
149
 
@@ -155,24 +154,18 @@ class Riffer::Rig::REPL
155
154
  "<skill name=\"#{name}\">\n#{body}\n</skill>"
156
155
  end
157
156
 
158
- # Tool call lines print below their round's stats line to reflect the actual
159
- # timeline (usage is reported when the model stream closes, after the calls
160
- # were decided). ToolCallDone events are therefore held until the stats line
161
- # or the next event forces them out. Callers must have the spinner stopped —
162
- # the flush renders between their existing stop/start pair.
157
+ # Chrome lines that never pass through the renderer (errors, exit line) end
158
+ # their line with a newline but leave the renderer's tool-group state alone —
159
+ # they can only follow prose, never mid-group.
163
160
  #
161
+ # @rbs &block: () -> String
164
162
  # @rbs return: void
165
- def flush_pending_tool_calls
166
- return if @pending_tool_calls.empty?
167
-
168
- @pending_tool_calls.each { |event| @renderer.render(event) }
169
- @pending_tool_calls = []
163
+ def print_block(&)
164
+ @output.puts
165
+ @output.puts(yield)
166
+ @output.flush
170
167
  end
171
168
 
172
- # Tool results can land mid-animation (tool execution emits no stream events,
173
- # so the indicator is up); stop it around the line so its next frame doesn't
174
- # erase what we printed.
175
- #
176
169
  # @rbs message: Riffer::Messages::Base
177
170
  # @rbs return: void
178
171
  def render_tool_result(message)
@@ -180,6 +173,17 @@ class Riffer::Rig::REPL
180
173
 
181
174
  @animator.stop
182
175
  @renderer.render_tool_result(message)
183
- @animator.start
176
+ start_animator
177
+ end
178
+
179
+ # Every animator start sits behind a smoother drain: the smoother's backlog
180
+ # may still be trickling out from streamed prose, and spinner frames drawn
181
+ # mid-drain would carve `\r…\e[K` through a half-printed sentence.
182
+ #
183
+ # @rbs mode: Symbol
184
+ # @rbs return: void
185
+ def start_animator(mode = :neutral)
186
+ @smoother.drain
187
+ @animator.start(mode)
184
188
  end
185
189
  end