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 +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/riffer/rig/cli.rb +10 -1
- data/lib/riffer/rig/coding_agent.rb +30 -28
- data/lib/riffer/rig/repl.rb +35 -43
- data/lib/riffer/rig/tools/bash.rb +4 -1
- data/lib/riffer/rig/tools/edit.rb +10 -2
- data/lib/riffer/rig/ui/renderer.rb +106 -29
- data/lib/riffer/rig/ui/smoother.rb +25 -9
- data/lib/riffer/rig/version.rb +1 -1
- data/sig/generated/riffer/rig/repl.rbs +4 -12
- data/sig/generated/riffer/rig/ui/renderer.rbs +55 -7
- data/sig/generated/riffer/rig/ui/smoother.rbs +6 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e833f2a4dfe0b87598e2be9d585f3cb772562ab39a2dd528684a38dca87f63ab
|
|
4
|
+
data.tar.gz: f469b328103a49e52773c0073e02d8c2fbd06d8cf6779daa50c09b49ab2eba8e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/riffer/rig/cli.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
data/lib/riffer/rig/repl.rb
CHANGED
|
@@ -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),
|
|
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
|
-
@
|
|
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
|
-
|
|
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::
|
|
83
|
-
# Tool
|
|
84
|
-
#
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
140
|
+
print_block { @theme.magenta("✦ skill: #{name}") }
|
|
142
141
|
skill_block(name, body)
|
|
143
142
|
rescue Riffer::ArgumentError
|
|
144
|
-
@
|
|
143
|
+
print_block { @theme.red("Unknown skill: #{name}") }
|
|
145
144
|
nil
|
|
146
145
|
rescue StandardError => e
|
|
147
|
-
@
|
|
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
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
@
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
34
|
+
render_prose(event.content)
|
|
31
35
|
when Riffer::StreamEvents::ToolCallDone
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
@io.puts(@theme.dim("[interrupted: #{event.reason}]"))
|
|
40
|
+
render_block(0) { @theme.dim("[interrupted: #{event.reason}]") }
|
|
40
41
|
when Riffer::StreamEvents::TokenUsageDone
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
@
|
|
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-
|
|
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
|
-
|
|
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
|
-
@
|
|
109
|
+
@newline_pending = false
|
|
110
|
+
@io.print("\n")
|
|
95
111
|
@io.flush
|
|
96
112
|
end
|
|
97
113
|
end
|
data/lib/riffer/rig/version.rb
CHANGED
|
@@ -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
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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
|
|
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
|
-
@
|
|
6
|
+
@prose_gap_pending: bool
|
|
7
7
|
|
|
8
|
-
@
|
|
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
|
-
@
|
|
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-
|
|
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.
|
|
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
|
|
83
|
-
|
|
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:
|