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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +2 -2
- data/lib/riffer/rig/cli.rb +31 -2
- data/lib/riffer/rig/coding_agent.rb +35 -31
- data/lib/riffer/rig/credentials.rb +24 -4
- data/lib/riffer/rig/repl.rb +75 -22
- data/lib/riffer/rig/settings/pricing.rb +19 -0
- data/lib/riffer/rig/settings.rb +33 -8
- data/lib/riffer/rig/token_tally.rb +22 -6
- data/lib/riffer/rig/tools/bash.rb +23 -5
- data/lib/riffer/rig/tools/edit.rb +16 -2
- data/lib/riffer/rig/tools/read.rb +6 -1
- data/lib/riffer/rig/tools/write.rb +4 -0
- data/lib/riffer/rig/ui/animator.rb +51 -11
- data/lib/riffer/rig/ui/banner.rb +28 -4
- data/lib/riffer/rig/ui/cursor.rb +14 -2
- data/lib/riffer/rig/ui/palette.rb +13 -7
- data/lib/riffer/rig/ui/renderer.rb +137 -29
- data/lib/riffer/rig/ui/smoother.rb +56 -14
- data/lib/riffer/rig/ui/theme.rb +38 -7
- data/lib/riffer/rig/version.rb +1 -1
- data/sig/generated/riffer/rig/cli.rbs +41 -0
- data/sig/generated/riffer/rig/coding_agent.rbs +9 -0
- data/sig/generated/riffer/rig/credentials.rbs +55 -0
- data/sig/generated/riffer/rig/repl.rbs +68 -0
- data/sig/generated/riffer/rig/settings/pricing.rbs +23 -0
- data/sig/generated/riffer/rig/settings.rbs +92 -0
- data/sig/generated/riffer/rig/token_tally.rbs +61 -0
- data/sig/generated/riffer/rig/tools/bash.rbs +28 -0
- data/sig/generated/riffer/rig/tools/edit.rbs +11 -0
- data/sig/generated/riffer/rig/tools/read.rbs +12 -0
- data/sig/generated/riffer/rig/tools/write.rbs +9 -0
- data/sig/generated/riffer/rig/ui/animator.rbs +76 -0
- data/sig/generated/riffer/rig/ui/banner.rbs +42 -0
- data/sig/generated/riffer/rig/ui/cursor.rbs +33 -0
- data/sig/generated/riffer/rig/ui/palette.rbs +17 -0
- data/sig/generated/riffer/rig/ui/renderer.rbs +116 -0
- data/sig/generated/riffer/rig/ui/smoother.rbs +72 -0
- data/sig/generated/riffer/rig/ui/theme.rbs +60 -0
- data/sig/generated/riffer/rig/version.rbs +7 -0
- data/sig/generated/riffer/rig.rbs +2 -0
- data/sig/manual/riffer/rig/cli.rbs +8 -0
- data/sig/manual/riffer/rig/credentials.rbs +8 -0
- data/sig/manual/riffer/rig/settings.rbs +8 -0
- data/sig/manual/riffer/rig/tools.rbs +5 -0
- data/sig/manual/riffer/rig/ui/banner.rbs +8 -0
- data/sig/manual/riffer/rig/ui.rbs +5 -0
- 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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
80
|
+
thread = @thread
|
|
81
|
+
return unless thread
|
|
59
82
|
|
|
60
83
|
@stop = true
|
|
61
|
-
|
|
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 +
|
|
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
|
data/lib/riffer/rig/ui/banner.rb
CHANGED
|
@@ -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}"
|
data/lib/riffer/rig/ui/cursor.rb
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
34
|
+
render_prose(event.content)
|
|
19
35
|
when Riffer::StreamEvents::ToolCallDone
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
@io.puts(@theme.dim("[interrupted: #{event.reason}]"))
|
|
40
|
+
render_block(0) { @theme.dim("[interrupted: #{event.reason}]") }
|
|
28
41
|
when Riffer::StreamEvents::TokenUsageDone
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|