riffer-rig 0.3.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01f34d2a4cb9347ea2d9f7a81a0969415c98b6622d4f78e123034f9b2c15509e
4
- data.tar.gz: 5a61c231fcf2832a514ec62513bdf42e56c87dc08ec994f9fb04fa263188b53d
3
+ metadata.gz: e833f2a4dfe0b87598e2be9d585f3cb772562ab39a2dd528684a38dca87f63ab
4
+ data.tar.gz: f469b328103a49e52773c0073e02d8c2fbd06d8cf6779daa50c09b49ab2eba8e
5
5
  SHA512:
6
- metadata.gz: 1b8518928f77909373cfdfe2783372aa1a6ebb04fed67a70951041c0adf637bf3aba0ee8b85051f9bdb63c1cdcda91554b2fd9c86b4f82a279eed30964984588
7
- data.tar.gz: 3cd5548405bcf8607d516e7f8a585cdf327cfc904e1641458aca4802e8a3654127c6ed44a46435003faeca1eaaafcd4bed0968c0787a90aca8c586438b685146
6
+ metadata.gz: 696e8b92a7c1b7a3b71e1f628894e89aed725fbe072dba712d94df277fc5730b06d74def094445b02842d0849409b7900038aedf5d65f29f5416d1450a5b5121
7
+ data.tar.gz: 3dc42ac6e625d292462e00119bd7b3c7a8d1f23ad3c4f66763fff96cbfc3328f962dca67bdc4613e884ba81a6e3f61d9af86f1ba1d8bbd16e0f3caa307ca453c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0](https://github.com/bottrall/riffer-rig/compare/v0.3.0...v0.4.0) (2026-09-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **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))
14
+
3
15
  ## [0.3.0](https://github.com/bottrall/riffer-rig/compare/v0.2.1...v0.3.0) (2026-09-10)
4
16
 
5
17
 
@@ -101,7 +101,16 @@ module Riffer::Rig::CLI
101
101
  loaded = [Riffer::Rig::CodingAgent::GLOBAL_AGENTS_FILE, File.join(Dir.pwd, 'AGENTS.md')].select { |path| File.file?(path) }
102
102
  context = loaded.empty? ? 'none' : loaded.join(', ')
103
103
 
104
- animator.reveal([Riffer::Rig::UI::Banner.lines(theme, model: model, cwd: Dir.pwd, context: context, skills: count_skills, version: Riffer::Rig::VERSION)])
104
+ animator.reveal(
105
+ [Riffer::Rig::UI::Banner.lines(
106
+ theme,
107
+ model: model,
108
+ cwd: Dir.pwd,
109
+ context: context,
110
+ skills: count_skills,
111
+ version: Riffer::Rig::VERSION
112
+ )]
113
+ )
105
114
  end
106
115
 
107
116
  # @rbs return: String
@@ -25,32 +25,34 @@ class Riffer::Rig::CodingAgent < Riffer::Agent
25
25
 
26
26
  max_steps nil
27
27
 
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)
28
+ instructions(
29
+ lambda do
30
+ context_files = [GLOBAL_AGENTS_FILE, File.join(Dir.pwd, 'AGENTS.md')]
31
+ .select { |path| File.file?(path) }
32
+ .map { |path| "<project_instructions path=\"#{path}\">\n#{File.read(path)}\n</project_instructions>" }
33
+
34
+ project_context =
35
+ if context_files.empty?
36
+ ''
37
+ else
38
+ "\n\n<project_context>\n#{context_files.join("\n\n")}\n</project_context>"
39
+ end
40
+
41
+ <<~PROMPT.chomp + project_context + "\n\nCurrent date: #{Date.today}\nCurrent working directory: #{Dir.pwd}"
42
+ 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.
43
+
44
+ Available tools:
45
+ - read: read a file's contents
46
+ - write: write content to a file
47
+ - edit: replace an exact string in a file
48
+ - bash: run a shell command in the working directory
49
+
50
+ Guidelines:
51
+ - Be concise and direct in your responses.
52
+ - Show file paths clearly when working with files.
53
+ - Use bash for file exploration (ls, rg, find) rather than guessing.
54
+ - Prefer editing existing files over creating new ones.
55
+ PROMPT
56
+ end
57
+ )
56
58
  end
@@ -13,7 +13,6 @@ class Riffer::Rig::REPL
13
13
  # @rbs @theme: Riffer::Rig::UI::Theme
14
14
  # @rbs @input: untyped
15
15
  # @rbs @output: untyped
16
- # @rbs @pending_tool_calls: Array[Riffer::StreamEvents::ToolCallDone]
17
16
 
18
17
  # @rbs ?agent: Riffer::Agent
19
18
  # @rbs ?renderer: Riffer::Rig::UI::Renderer
@@ -24,7 +23,16 @@ class Riffer::Rig::REPL
24
23
  # @rbs ?smoother: Riffer::Rig::UI::Smoother
25
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(agent:, renderer:, input: $stdin, output: $stdout, theme: Riffer::Rig::UI::Theme.for(output),
27
+ animator: Riffer::Rig::UI::Animator.new(
28
+ io: output,
29
+ theme:
30
+ ), smoother: Riffer::Rig::UI::Smoother.new(
31
+ io: output,
32
+ theme:
33
+ ), cursor: Riffer::Rig::UI::Cursor.new(
34
+ 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
 
@@ -76,40 +83,32 @@ class Riffer::Rig::REPL
76
83
  when Riffer::StreamEvents::ReasoningDone
77
84
  @animator.start
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
92
+ @animator.start
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)
95
98
  @animator.start
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,17 @@ 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
  #
164
161
  # @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 = []
162
+ def print_block(&)
163
+ @output.puts
164
+ @output.puts(yield)
165
+ @output.flush
170
166
  end
171
167
 
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
168
  # @rbs message: Riffer::Messages::Base
177
169
  # @rbs return: void
178
170
  def render_tool_result(message)
@@ -15,7 +15,10 @@ 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?
@@ -9,7 +9,10 @@ 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?
@@ -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.
@@ -9,6 +9,8 @@ class Riffer::Rig::UI::Renderer
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
15
  # @rbs io: untyped
14
16
  # @rbs ?theme: Riffer::Rig::UI::Theme
@@ -20,6 +22,8 @@ class Riffer::Rig::UI::Renderer
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,125 @@ 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 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
+
57
153
  # @rbs return: Riffer::Rig::UI::Smoother | PassThroughSmoother
58
154
  def smoother
59
155
  @smoother || PassThroughSmoother.new(@io)
@@ -88,25 +184,6 @@ class Riffer::Rig::UI::Renderer
88
184
  smoother.drain
89
185
  end
90
186
 
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
187
  # @rbs arguments: String
111
188
  # @rbs return: String
112
189
  def format_arguments(arguments)
@@ -25,6 +25,7 @@ class Riffer::Rig::UI::Smoother
25
25
  # @rbs @mutex: Mutex
26
26
  # @rbs @thread: Thread?
27
27
  # @rbs @stop: bool
28
+ # @rbs @newline_pending: bool
28
29
 
29
30
  # @rbs io: untyped
30
31
  # @rbs ?theme: Riffer::Rig::UI::Theme
@@ -39,6 +40,7 @@ class Riffer::Rig::UI::Smoother
39
40
  @mutex = Mutex.new
40
41
  @thread = nil
41
42
  @stop = false
43
+ @newline_pending = false
42
44
  end
43
45
 
44
46
  # @rbs return: void
@@ -58,11 +60,14 @@ class Riffer::Rig::UI::Smoother
58
60
  # @rbs content: String
59
61
  # @rbs return: self
60
62
  def <<(content)
61
- if enabled?
62
- @mutex.synchronize { @backlog << content }
63
- else
64
- @io.print(content)
65
- @io.flush
63
+ @mutex.synchronize do
64
+ if enabled?
65
+ @backlog << content
66
+ else
67
+ @io.print(content)
68
+ @io.flush
69
+ end
70
+ @newline_pending = !content.end_with?("\n")
66
71
  end
67
72
  self
68
73
  end
@@ -79,19 +84,30 @@ class Riffer::Rig::UI::Smoother
79
84
  return if count.zero?
80
85
 
81
86
  @carry -= count
82
- @io.print(@backlog.slice!(0, count))
87
+ chunk = @backlog.slice!(0, count) || ''
88
+ @newline_pending = !chunk.end_with?("\n")
89
+ @io.print(chunk)
83
90
  @io.flush
84
91
  end
85
92
  end
86
93
 
87
- # Called before non-delta output so printed order matches stream order.
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.
88
98
  #
89
99
  # @rbs return: void
90
100
  def drain
91
101
  @mutex.synchronize do
92
- return if @backlog.empty?
102
+ unless @backlog.empty?
103
+ backlog = @backlog.slice!(0, @backlog.length) || ''
104
+ @newline_pending = !backlog.end_with?("\n")
105
+ @io.print(backlog)
106
+ end
107
+ return unless @newline_pending
93
108
 
94
- @io.print(@backlog.slice!(0, @backlog.length))
109
+ @newline_pending = false
110
+ @io.print("\n")
95
111
  @io.flush
96
112
  end
97
113
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Riffer
4
4
  module Rig
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
@@ -5,8 +5,6 @@ class Riffer::Rig::REPL
5
5
 
6
6
  SKILL_COMMAND: Regexp
7
7
 
8
- @pending_tool_calls: Array[Riffer::StreamEvents::ToolCallDone]
9
-
10
8
  @output: untyped
11
9
 
12
10
  @input: untyped
@@ -57,19 +55,13 @@ class Riffer::Rig::REPL
57
55
  # @rbs return: String
58
56
  def skill_block: (String name, String body) -> String
59
57
 
60
- # Tool call lines print below their round's stats line to reflect the actual
61
- # timeline (usage is reported when the model stream closes, after the calls
62
- # were decided). ToolCallDone events are therefore held until the stats line
63
- # or the next event forces them out. Callers must have the spinner stopped —
64
- # the flush renders between their existing stop/start pair.
58
+ # Chrome lines that never pass through the renderer (errors, exit line) end
59
+ # their line with a newline but leave the renderer's tool-group state alone —
60
+ # they can only follow prose, never mid-group.
65
61
  #
66
62
  # @rbs return: void
67
- def flush_pending_tool_calls: () -> void
63
+ def print_block: () ?{ (?) -> untyped } -> void
68
64
 
69
- # Tool results can land mid-animation (tool execution emits no stream events,
70
- # so the indicator is up); stop it around the line so its next frame doesn't
71
- # erase what we printed.
72
- #
73
65
  # @rbs message: Riffer::Messages::Base
74
66
  # @rbs return: void
75
67
  def render_tool_result: (Riffer::Messages::Base message) -> void
@@ -3,13 +3,17 @@
3
3
  class Riffer::Rig::UI::Renderer
4
4
  RESULT_PREVIEW_LIMIT: Integer
5
5
 
6
- @io: untyped
6
+ @prose_gap_pending: bool
7
7
 
8
- @theme: Riffer::Rig::UI::Theme
8
+ @deferred_usage: Riffer::Providers::TokenUsage?
9
+
10
+ @smoother: Riffer::Rig::UI::Smoother | PassThroughSmoother?
9
11
 
10
12
  @tally: Riffer::Rig::TokenTally?
11
13
 
12
- @smoother: Riffer::Rig::UI::Smoother | PassThroughSmoother?
14
+ @theme: Riffer::Rig::UI::Theme
15
+
16
+ @io: untyped
13
17
 
14
18
  # @rbs io: untyped
15
19
  # @rbs ?theme: Riffer::Rig::UI::Theme
@@ -22,12 +26,60 @@ class Riffer::Rig::UI::Renderer
22
26
  # @rbs return: void
23
27
  def render: (Riffer::StreamEvents::Base event) -> void
24
28
 
29
+ # The prompt holds the line open for typed input, so unlike render_block it
30
+ # ends without a newline. It sits at the renderer so a tool group left open by
31
+ # the previous turn closes here, and the next turn's blocks open cleanly.
32
+ #
33
+ # @rbs return: void
34
+ def prompt: () -> void
35
+
36
+ # Usage arrives per model call but tool results arrive via the session
37
+ # callback after each call's stream ends, so rendering inline would print the
38
+ # stats above the results they belong with. Held until the turn's output is
39
+ # done, then printed as one line below it.
40
+ #
41
+ # @rbs return: void
42
+ def flush_usage: () -> void
43
+
25
44
  # @rbs message: Riffer::Messages::Base
26
45
  # @rbs return: void
27
46
  def render_tool_result: (Riffer::Messages::Base message) -> void
28
47
 
29
48
  private
30
49
 
50
+ # Rule 2 for one-off blocks (skill line, interrupt, stats): one blank line
51
+ # above, no indent. The smoother drains first so any pending partial prose
52
+ # block ends cleanly before the gap is written, and any open tool group closes
53
+ # so the next group opens with its own gap.
54
+ #
55
+ # @rbs indent: Integer
56
+ # @rbs return: void
57
+ def render_block: (Integer indent) ?{ (?) -> untyped } -> void
58
+
59
+ # The blank line above a prose block is written at the first delta after a
60
+ # non-prose block. Streaming makes "which block is first?" a stateful
61
+ # question, so the answer is tracked rather than embedded at each render site.
62
+ #
63
+ # @rbs content: String
64
+ # @rbs return: void
65
+ def render_prose: (String content) -> void
66
+
67
+ # @rbs indent: Integer
68
+ # @rbs return: void
69
+ def render_tool_activity: (Integer indent) ?{ (?) -> untyped } -> void
70
+
71
+ # Tool-activity lines (⚙ calls, ↳ results) share one blank line above the
72
+ # group instead of one between each line — the gap goes between groups, not
73
+ # inside a pair. The group stays open so prose after it pays the closing gap.
74
+ #
75
+ # @rbs return: void
76
+ def open_tool_activity: () -> void
77
+
78
+ # @rbs usage: Riffer::Providers::TokenUsage
79
+ # @rbs tally: Riffer::Rig::TokenTally
80
+ # @rbs return: String
81
+ def usage_line: (Riffer::Providers::TokenUsage usage, Riffer::Rig::TokenTally tally) -> String
82
+
31
83
  # @rbs return: Riffer::Rig::UI::Smoother | PassThroughSmoother
32
84
  def smoother: () -> (Riffer::Rig::UI::Smoother | PassThroughSmoother)
33
85
 
@@ -54,10 +106,6 @@ class Riffer::Rig::UI::Renderer
54
106
  # @rbs return: void
55
107
  def drain_smoother: () -> void
56
108
 
57
- # @rbs usage: Riffer::Providers::TokenUsage
58
- # @rbs return: void
59
- def render_token_usage: (Riffer::Providers::TokenUsage usage) -> void
60
-
61
109
  # @rbs arguments: String
62
110
  # @rbs return: String
63
111
  def format_arguments: (String arguments) -> String
@@ -16,6 +16,8 @@ class Riffer::Rig::UI::Smoother
16
16
 
17
17
  MIN_CHARS_PER_TICK: Rational
18
18
 
19
+ @newline_pending: bool
20
+
19
21
  @stop: bool
20
22
 
21
23
  @thread: Thread?
@@ -50,7 +52,10 @@ class Riffer::Rig::UI::Smoother
50
52
  # @rbs return: void
51
53
  def tick: () -> void
52
54
 
53
- # Called before non-delta output so printed order matches stream order.
55
+ # Called before non-prose output so printed order matches stream order. The
56
+ # backlog is flushed, then any partial prose block the model left without a
57
+ # trailing newline gets one, so block spacing never depends on the model's
58
+ # last character.
54
59
  #
55
60
  # @rbs return: void
56
61
  def drain: () -> void
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: riffer-rig
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall
@@ -79,8 +79,8 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '2.8'
82
- description: An interactive terminal coding agent (read/write/edit/bash) built on
83
- the riffer agentic framework.
82
+ description: An interactive terminal agent (read/write/edit/bash) built on the riffer
83
+ agentic framework.
84
84
  email:
85
85
  - jakebottrall@gmail.com
86
86
  executables: