riffer-rig 0.5.0 → 0.7.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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +77 -7
  4. data/exe/riffer +1 -1
  5. data/lib/riffer/rig/bundled/agents_md.rb +7 -0
  6. data/lib/riffer/rig/bundled/bash.rb +5 -0
  7. data/lib/riffer/rig/bundled/edit.rb +5 -0
  8. data/lib/riffer/rig/bundled/mcp.rb +9 -0
  9. data/lib/riffer/rig/bundled/read.rb +5 -0
  10. data/lib/riffer/rig/bundled/skills.rb +10 -0
  11. data/lib/riffer/rig/bundled/write.rb +5 -0
  12. data/lib/riffer/rig/bundled.rb +7 -0
  13. data/lib/riffer/rig/cli/flags.rb +77 -0
  14. data/lib/riffer/rig/cli.rb +70 -102
  15. data/lib/riffer/rig/command/context.rb +70 -0
  16. data/lib/riffer/rig/command.rb +29 -0
  17. data/lib/riffer/rig/commands/model.rb +54 -0
  18. data/lib/riffer/rig/commands/skill.rb +27 -0
  19. data/lib/riffer/rig/commands.rb +4 -0
  20. data/lib/riffer/rig/credentials/resolution.rb +16 -0
  21. data/lib/riffer/rig/credentials.rb +151 -60
  22. data/lib/riffer/rig/directories.rb +20 -0
  23. data/lib/riffer/rig/env/invalid.rb +13 -0
  24. data/lib/riffer/rig/env.rb +40 -0
  25. data/lib/riffer/rig/events/after_response.rb +25 -0
  26. data/lib/riffer/rig/events/after_tool_call.rb +31 -0
  27. data/lib/riffer/rig/events/before_prompt.rb +25 -0
  28. data/lib/riffer/rig/events/before_request.rb +25 -0
  29. data/lib/riffer/rig/events/before_tool_call.rb +28 -0
  30. data/lib/riffer/rig/events/command_output.rb +28 -0
  31. data/lib/riffer/rig/events/notify.rb +28 -0
  32. data/lib/riffer/rig/events/session_end.rb +25 -0
  33. data/lib/riffer/rig/events/session_start.rb +28 -0
  34. data/lib/riffer/rig/events/skill_activated.rb +25 -0
  35. data/lib/riffer/rig/events/turn_end.rb +33 -0
  36. data/lib/riffer/rig/events.rb +4 -0
  37. data/lib/riffer/rig/extension/failure.rb +16 -0
  38. data/lib/riffer/rig/extension.rb +16 -5
  39. data/lib/riffer/rig/hosts/mirror.rb +59 -0
  40. data/lib/riffer/rig/hosts/null.rb +36 -0
  41. data/lib/riffer/rig/loader.rb +163 -0
  42. data/lib/riffer/rig/mcp/declaration.rb +23 -0
  43. data/lib/riffer/rig/mcp/server.rb +16 -0
  44. data/lib/riffer/rig/mcp.rb +12 -0
  45. data/lib/riffer/rig/prompts/agents_md.rb +32 -0
  46. data/lib/riffer/rig/prompts.rb +4 -0
  47. data/lib/riffer/rig/provider_setup/field.rb +24 -0
  48. data/lib/riffer/rig/provider_setup.rb +84 -0
  49. data/lib/riffer/rig/registrar.rb +134 -7
  50. data/lib/riffer/rig/runtime/blocked.rb +13 -0
  51. data/lib/riffer/rig/runtime/cancel_flag.rb +27 -0
  52. data/lib/riffer/rig/runtime/hooks.rb +78 -0
  53. data/lib/riffer/rig/runtime/request_guardrail.rb +24 -0
  54. data/lib/riffer/rig/runtime/tool_runtime.rb +44 -0
  55. data/lib/riffer/rig/runtime.rb +582 -70
  56. data/lib/riffer/rig/settings/document.rb +28 -12
  57. data/lib/riffer/rig/settings/pricing.rb +16 -6
  58. data/lib/riffer/rig/settings.rb +108 -81
  59. data/lib/riffer/rig/skills/sources.rb +28 -0
  60. data/lib/riffer/rig/skills.rb +12 -0
  61. data/lib/riffer/rig/support/equatable.rb +21 -0
  62. data/lib/riffer/rig/{ui → terminal}/animator.rb +6 -13
  63. data/lib/riffer/rig/{ui → terminal}/banner.rb +17 -21
  64. data/lib/riffer/rig/{ui → terminal}/cursor.rb +4 -6
  65. data/lib/riffer/rig/terminal/host.rb +85 -0
  66. data/lib/riffer/rig/{ui → terminal}/palette.rb +1 -1
  67. data/lib/riffer/rig/{ui → terminal}/renderer.rb +121 -87
  68. data/lib/riffer/rig/{ui → terminal}/smoother.rb +15 -23
  69. data/lib/riffer/rig/{ui → terminal}/theme.rb +12 -14
  70. data/lib/riffer/rig/terminal.rb +207 -0
  71. data/lib/riffer/rig/tools/bash.rb +36 -13
  72. data/lib/riffer/rig/tools/edit.rb +3 -2
  73. data/lib/riffer/rig/tools/read.rb +3 -2
  74. data/lib/riffer/rig/tools/write.rb +3 -2
  75. data/lib/riffer/rig/version.rb +1 -1
  76. data/lib/riffer/rig.rb +10 -15
  77. data/sig/generated/riffer/rig/bundled/agents_md.rbs +5 -0
  78. data/sig/generated/riffer/rig/bundled/bash.rbs +5 -0
  79. data/sig/generated/riffer/rig/bundled/edit.rbs +5 -0
  80. data/sig/generated/riffer/rig/bundled/mcp.rbs +5 -0
  81. data/sig/generated/riffer/rig/bundled/read.rbs +5 -0
  82. data/sig/generated/riffer/rig/bundled/skills.rbs +5 -0
  83. data/sig/generated/riffer/rig/bundled/write.rbs +5 -0
  84. data/sig/generated/riffer/rig/bundled.rbs +5 -0
  85. data/sig/generated/riffer/rig/cli/flags.rbs +40 -0
  86. data/sig/generated/riffer/rig/cli.rbs +30 -27
  87. data/sig/generated/riffer/rig/command/context.rbs +50 -0
  88. data/sig/generated/riffer/rig/command.rbs +23 -0
  89. data/sig/generated/riffer/rig/commands/model.rbs +27 -0
  90. data/sig/generated/riffer/rig/commands/skill.rbs +12 -0
  91. data/sig/generated/riffer/rig/commands.rbs +4 -0
  92. data/sig/generated/riffer/rig/credentials/resolution.rbs +13 -0
  93. data/sig/generated/riffer/rig/credentials.rbs +70 -41
  94. data/sig/generated/riffer/rig/directories.rbs +11 -0
  95. data/sig/generated/riffer/rig/env/invalid.rbs +10 -0
  96. data/sig/generated/riffer/rig/env.rbs +26 -0
  97. data/sig/generated/riffer/rig/events/after_response.rbs +18 -0
  98. data/sig/generated/riffer/rig/events/after_tool_call.rbs +24 -0
  99. data/sig/generated/riffer/rig/events/before_prompt.rbs +18 -0
  100. data/sig/generated/riffer/rig/events/before_request.rbs +18 -0
  101. data/sig/generated/riffer/rig/events/before_tool_call.rbs +21 -0
  102. data/sig/generated/riffer/rig/events/command_output.rbs +21 -0
  103. data/sig/generated/riffer/rig/events/notify.rbs +21 -0
  104. data/sig/generated/riffer/rig/events/session_end.rbs +18 -0
  105. data/sig/generated/riffer/rig/events/session_start.rbs +21 -0
  106. data/sig/generated/riffer/rig/events/skill_activated.rbs +18 -0
  107. data/sig/generated/riffer/rig/events/turn_end.rbs +24 -0
  108. data/sig/generated/riffer/rig/events.rbs +4 -0
  109. data/sig/generated/riffer/rig/extension/failure.rbs +13 -0
  110. data/sig/generated/riffer/rig/extension.rbs +14 -5
  111. data/sig/generated/riffer/rig/hosts/mirror.rbs +41 -0
  112. data/sig/generated/riffer/rig/hosts/null.rbs +26 -0
  113. data/sig/generated/riffer/rig/loader.rbs +77 -0
  114. data/sig/generated/riffer/rig/mcp/declaration.rbs +18 -0
  115. data/sig/generated/riffer/rig/mcp/server.rbs +13 -0
  116. data/sig/generated/riffer/rig/mcp.rbs +8 -0
  117. data/sig/generated/riffer/rig/prompts/agents_md.rbs +19 -0
  118. data/sig/generated/riffer/rig/prompts.rbs +4 -0
  119. data/sig/generated/riffer/rig/provider_setup/field.rbs +22 -0
  120. data/sig/generated/riffer/rig/provider_setup.rbs +30 -0
  121. data/sig/generated/riffer/rig/registrar.rbs +88 -6
  122. data/sig/generated/riffer/rig/runtime/blocked.rbs +10 -0
  123. data/sig/generated/riffer/rig/runtime/cancel_flag.rbs +19 -0
  124. data/sig/generated/riffer/rig/runtime/hooks.rbs +49 -0
  125. data/sig/generated/riffer/rig/runtime/request_guardrail.rbs +16 -0
  126. data/sig/generated/riffer/rig/runtime/tool_runtime.rbs +23 -0
  127. data/sig/generated/riffer/rig/runtime.rbs +262 -40
  128. data/sig/generated/riffer/rig/settings/document.rbs +13 -9
  129. data/sig/generated/riffer/rig/settings/pricing.rbs +7 -3
  130. data/sig/generated/riffer/rig/settings.rbs +56 -64
  131. data/sig/generated/riffer/rig/skills/sources.rbs +18 -0
  132. data/sig/generated/riffer/rig/skills.rbs +7 -0
  133. data/sig/generated/riffer/rig/support/equatable.rbs +15 -0
  134. data/sig/generated/riffer/rig/{ui → terminal}/animator.rbs +5 -13
  135. data/sig/generated/riffer/rig/terminal/banner.rbs +39 -0
  136. data/sig/generated/riffer/rig/terminal/cursor.rbs +31 -0
  137. data/sig/generated/riffer/rig/terminal/host.rbs +50 -0
  138. data/sig/generated/riffer/rig/{ui → terminal}/palette.rbs +2 -2
  139. data/sig/generated/riffer/rig/terminal/renderer.rbs +126 -0
  140. data/sig/generated/riffer/rig/terminal/smoother.rbs +59 -0
  141. data/sig/generated/riffer/rig/{ui → terminal}/theme.rbs +5 -7
  142. data/sig/generated/riffer/rig/terminal.rbs +85 -0
  143. data/sig/generated/riffer/rig/tools/bash.rbs +14 -1
  144. data/sig/generated/riffer/rig.rbs +4 -7
  145. data/sig/manual/riffer/rig/cli.rbs +2 -4
  146. data/sig/manual/riffer/rig/commands/model.rbs +6 -0
  147. data/sig/manual/riffer/rig/commands/skill.rbs +6 -0
  148. data/sig/manual/riffer/rig/credentials.rbs +2 -4
  149. data/sig/manual/riffer/rig/directories.rbs +6 -0
  150. data/sig/manual/riffer/rig/events.rbs +6 -0
  151. data/sig/manual/riffer/rig/hosts.rbs +12 -0
  152. data/sig/manual/riffer/rig/mcp.rbs +11 -0
  153. data/sig/manual/riffer/rig/prompts/agents_md.rbs +6 -0
  154. data/sig/manual/riffer/rig/settings.rbs +2 -4
  155. data/sig/manual/riffer/rig/skills.rbs +6 -0
  156. data/sig/manual/riffer/rig/support.rbs +8 -0
  157. data/sig/manual/riffer/rig/terminal/banner.rbs +6 -0
  158. metadata +173 -35
  159. data/lib/riffer/rig/coding_agent.rb +0 -53
  160. data/lib/riffer/rig/host.rb +0 -52
  161. data/lib/riffer/rig/repl.rb +0 -189
  162. data/lib/riffer/rig/token_tally.rb +0 -74
  163. data/sig/generated/riffer/rig/coding_agent.rbs +0 -9
  164. data/sig/generated/riffer/rig/host.rbs +0 -42
  165. data/sig/generated/riffer/rig/repl.rbs +0 -77
  166. data/sig/generated/riffer/rig/token_tally.rbs +0 -61
  167. data/sig/generated/riffer/rig/ui/banner.rbs +0 -42
  168. data/sig/generated/riffer/rig/ui/cursor.rbs +0 -33
  169. data/sig/generated/riffer/rig/ui/renderer.rbs +0 -118
  170. data/sig/generated/riffer/rig/ui/smoother.rbs +0 -72
  171. data/sig/manual/riffer/rig/ui/banner.rbs +0 -8
  172. data/sig/manual/riffer/rig/ui.rbs +0 -5
@@ -2,31 +2,34 @@
2
2
 
3
3
  require 'json'
4
4
 
5
- class Riffer::Rig::UI::Renderer
5
+ class Riffer::Rig::Terminal::Renderer
6
6
  RESULT_PREVIEW_LIMIT = 200 #: Integer
7
7
 
8
8
  # @rbs @io: IO
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?
9
+ # @rbs @theme: Riffer::Rig::Terminal::Theme
10
+ # @rbs @smoother: Riffer::Rig::Terminal::Smoother | PassThroughSmoother
11
+ # @rbs @cursor: Riffer::Rig::Terminal::Cursor
13
12
  # @rbs @prose_gap_pending: bool
14
13
 
15
14
  # @rbs io: IO
16
- # @rbs theme: Riffer::Rig::UI::Theme
17
- # @rbs tally: Riffer::Rig::TokenTally?
18
- # @rbs smoother: Riffer::Rig::UI::Smoother | PassThroughSmoother?
15
+ # @rbs theme: Riffer::Rig::Terminal::Theme
16
+ # @rbs smoother: Riffer::Rig::Terminal::Smoother | PassThroughSmoother
17
+ # @rbs cursor: Riffer::Rig::Terminal::Cursor
19
18
  # @rbs return: void
20
- def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io), tally: nil, smoother: nil)
19
+ def initialize(
20
+ io: $stdout,
21
+ theme: Riffer::Rig::Terminal::Theme.for(io),
22
+ smoother: PassThroughSmoother.new(io),
23
+ cursor: Riffer::Rig::Terminal::Cursor.new(io: io, theme: theme)
24
+ )
21
25
  @io = io
22
26
  @theme = theme
23
- @tally = tally
24
27
  @smoother = smoother
25
- @deferred_usage = nil
28
+ @cursor = cursor
26
29
  @prose_gap_pending = false
27
30
  end
28
31
 
29
- # @rbs event: Riffer::StreamEvents::Base
32
+ # @rbs event: Riffer::StreamEvents::TextDelta | Riffer::StreamEvents::ToolCallDone | Riffer::StreamEvents::Interrupt
30
33
  # @rbs return: void
31
34
  def render(event)
32
35
  case event
@@ -34,42 +37,91 @@ class Riffer::Rig::UI::Renderer
34
37
  render_prose(event.content)
35
38
  when Riffer::StreamEvents::ToolCallDone
36
39
  render_tool_activity(2) { @theme.cyan("⚙ #{event.name}(#{format_arguments(event.arguments)})") }
37
- when Riffer::StreamEvents::SkillActivation
38
- render_block(0) { @theme.magenta("✦ skill: #{event.name}") }
39
40
  when Riffer::StreamEvents::Interrupt
40
41
  render_block(0) { @theme.dim("[interrupted: #{event.reason}]") }
41
- when Riffer::StreamEvents::TokenUsageDone
42
- current = @deferred_usage
43
- @deferred_usage = current ? current + event.token_usage : event.token_usage
44
42
  end
45
43
  end
46
44
 
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
- #
45
+ # @rbs name: String
46
+ # @rbs return: void
47
+ def skill(name)
48
+ render_block(0) { @theme.magenta("✦ skill: #{name}") }
49
+ end
50
+
51
+ # @rbs text: String
52
+ # @rbs return: void
53
+ def notice(text)
54
+ render_block(0) { @theme.grey(text) }
55
+ end
56
+
57
+ # @rbs text: String
58
+ # @rbs return: void
59
+ def error(text)
60
+ render_block(0) { @theme.red(text) }
61
+ end
62
+
63
+ # @rbs message: String
64
+ # @rbs level: Symbol
65
+ # @rbs return: void
66
+ def notify(message, level)
67
+ level == :error ? error(message) : notice(message)
68
+ end
69
+
70
+ # @rbs usage: Riffer::Providers::TokenUsage?
71
+ # @rbs session: Riffer::Providers::TokenUsage?
72
+ # @rbs return: void
73
+ def usage(usage, session)
74
+ return unless usage && session
75
+
76
+ render_block(0) { @theme.dim(usage_line(usage, session)) }
77
+ end
78
+
51
79
  # @rbs return: void
52
80
  def prompt
81
+ # Closes any tool group the previous turn left open, so the next turn's
82
+ # blocks open cleanly.
53
83
  @prose_gap_pending = true
54
84
  @io.puts
55
85
  @io.print("#{@theme.pink('›')} ")
56
86
  @io.flush
57
87
  end
58
88
 
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
- #
89
+ # @rbs question: String?
90
+ # @rbs options: Array[String]?
91
+ # @rbs secret: bool
64
92
  # @rbs return: void
65
- def flush_usage
66
- usage = @deferred_usage
67
- @deferred_usage = nil
68
- tally = @tally
69
- return unless usage && tally
93
+ def question(question, options, secret)
94
+ drain
95
+ @io.puts
96
+ @io.puts(question) if question
97
+ options&.each_with_index { |option, index| @io.puts(" #{@theme.magenta("#{index + 1}.")} #{option}") }
98
+ @io.print("#{@theme.pink('›')} #{@theme.grey('(hidden) ') if secret}")
99
+ @io.flush
100
+ end
70
101
 
71
- tally.add(usage)
72
- render_block(0) { @theme.dim(usage_line(usage, tally)) }
102
+ # @rbs return: void
103
+ def newline
104
+ @io.puts
105
+ @io.flush
106
+ end
107
+
108
+ # @rbs return: void
109
+ def begin_turn
110
+ # A hidden cursor can't flicker against the animator's erase-and-redraw
111
+ # churn.
112
+ @cursor.hide
113
+ @smoother.start
114
+ end
115
+
116
+ # @rbs return: void
117
+ def end_turn
118
+ @smoother.finish
119
+ @cursor.show
120
+ end
121
+
122
+ # @rbs return: void
123
+ def drain
124
+ @smoother.drain
73
125
  end
74
126
 
75
127
  # @rbs message: Riffer::Messages::Base
@@ -84,36 +136,54 @@ class Riffer::Rig::UI::Renderer
84
136
  @io.flush
85
137
  end
86
138
 
139
+ class PassThroughSmoother
140
+ # @rbs @io: IO
141
+
142
+ # @rbs io: IO
143
+ # @rbs return: void
144
+ def initialize(io) = @io = io
145
+
146
+ # @rbs content: String
147
+ # @rbs return: self
148
+ def <<(content)
149
+ @io.print(content)
150
+ @io.flush
151
+ self
152
+ end
153
+
154
+ # @rbs return: nil
155
+ def start = nil
156
+
157
+ # @rbs return: nil
158
+ def drain = nil
159
+
160
+ # @rbs return: nil
161
+ def finish = nil
162
+ end
163
+
87
164
  private
88
165
 
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
166
  # @rbs indent: Integer
95
167
  # @rbs &block: () -> String
96
168
  # @rbs return: void
97
169
  def render_block(indent, &)
98
- drain_smoother
170
+ drain
99
171
  @io.puts
100
172
  @prose_gap_pending = true
101
173
  @io.puts((' ' * indent) + yield)
102
174
  @io.flush
103
175
  end
104
176
 
105
- # The blank line above a prose block is written at the first delta after a
106
- # non-prose block. Streaming makes "which block is first?" a stateful
107
- # question, so the answer is tracked rather than embedded at each render site.
108
- #
109
177
  # @rbs content: String
110
178
  # @rbs return: void
111
179
  def render_prose(content)
180
+ # Streaming makes "which delta opens a prose block?" a stateful question,
181
+ # so the gap is tracked in a flag rather than written at each render site.
112
182
  if @prose_gap_pending
113
183
  @prose_gap_pending = false
114
184
  @io.print("\n")
115
185
  end
116
- smoother << content
186
+ @smoother << content
117
187
  end
118
188
 
119
189
  # @rbs indent: Integer
@@ -125,67 +195,31 @@ class Riffer::Rig::UI::Renderer
125
195
  @io.flush
126
196
  end
127
197
 
128
- # Tool-activity lines (⚙ calls, ↳ results) share one blank line above the
129
- # group instead of one between each line — the gap goes between groups, not
130
- # inside a pair. The group stays open so prose after it pays the closing gap.
131
- #
132
198
  # @rbs return: void
133
199
  def open_tool_activity
200
+ # Tool-activity lines (⚙ calls, ↳ results) share one blank line above the
201
+ # group; the group stays open so the prose after it pays the closing gap.
134
202
  return if @prose_gap_pending
135
203
 
136
- drain_smoother
204
+ drain
137
205
  @io.puts
138
206
  @prose_gap_pending = true
139
207
  end
140
208
 
141
209
  # @rbs usage: Riffer::Providers::TokenUsage
142
- # @rbs tally: Riffer::Rig::TokenTally
210
+ # @rbs session: Riffer::Providers::TokenUsage
143
211
  # @rbs return: String
144
- def usage_line(usage, tally)
212
+ def usage_line(usage, session)
145
213
  parts = ["↑#{usage.input_tokens}", "↓#{usage.output_tokens}"]
146
214
  parts << "cache_write:#{usage.cache_write_tokens}" if usage.cache_write_tokens&.positive?
147
215
  parts << "cache_read:#{usage.cache_read_tokens}" if usage.cache_read_tokens&.positive?
148
- parts << "session #{tally.total_tokens} tok"
149
- cost = tally.estimated_cost
216
+ parts << "session #{session.total_tokens} tok"
217
+ cost = session.cost
150
218
  parts << format('~$%.4f', cost) if cost
151
219
 
152
220
  parts.join(' · ')
153
221
  end
154
222
 
155
- # @rbs return: Riffer::Rig::UI::Smoother | PassThroughSmoother
156
- def smoother
157
- @smoother || PassThroughSmoother.new(@io)
158
- end
159
-
160
- # Stand-in when no smoother is injected, so a bare Riffer::Rig::UI::Renderer still prints
161
- # synchronously.
162
- class PassThroughSmoother
163
- # @rbs @io: IO
164
-
165
- # @rbs io: IO
166
- # @rbs return: void
167
- def initialize(io) = @io = io
168
-
169
- # @rbs content: String
170
- # @rbs return: self
171
- def <<(content)
172
- @io.print(content)
173
- @io.flush
174
- self
175
- end
176
-
177
- # @rbs return: nil
178
- def drain = nil
179
-
180
- # @rbs return: nil
181
- def finish = nil
182
- end
183
-
184
- # @rbs return: void
185
- def drain_smoother
186
- smoother.drain
187
- end
188
-
189
223
  # @rbs arguments: String
190
224
  # @rbs return: String
191
225
  def format_arguments(arguments)
@@ -1,24 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Paces streamed text onto a 60fps cadence so API bursts read as continuous
4
- # typing. On a non-TTY output it is transparent: writes pass through
5
- # synchronously and no thread ever runs.
6
- class Riffer::Rig::UI::Smoother
3
+ class Riffer::Rig::Terminal::Smoother
7
4
  TICK_SECONDS = 1.0 / 60.0 #: Float
8
5
 
9
- # A frame removes only a sixtieth of the remaining backlog — an exponential
10
- # decay whose tail keeps text flowing for a second or more, so the reveal
11
- # never visibly stops between chunks. The carry accrues sub-character
12
- # releases so the effective rate can settle at the provider's throughput;
13
- # the floor is fractional (10 chars/s) so it can't over-drain a backlog
14
- # that's trickling in slower than 60 chars/s.
15
- #
6
+ # Releasing a fraction of the backlog, not a fixed rate, makes the reveal an
7
+ # exponential decay: its tail keeps text flowing for a second or more, so
8
+ # output never visibly stops between chunks.
16
9
  BACKLOG_FRACTION_PER_TICK = Rational(1, 60) #: Rational
17
10
 
11
+ # Fractional (10 chars/s) so the floor can't over-drain a backlog that's
12
+ # trickling in slower than 60 chars/s.
18
13
  MIN_CHARS_PER_TICK = Rational(1, 6) #: Rational
19
14
 
20
15
  # @rbs @io: IO
21
- # @rbs @theme: Riffer::Rig::UI::Theme
16
+ # @rbs @theme: Riffer::Rig::Terminal::Theme
22
17
  # @rbs @clock: singleton(Kernel)
23
18
  # @rbs @backlog: String
24
19
  # @rbs @carry: Rational
@@ -28,10 +23,10 @@ class Riffer::Rig::UI::Smoother
28
23
  # @rbs @newline_pending: bool
29
24
 
30
25
  # @rbs io: IO
31
- # @rbs theme: Riffer::Rig::UI::Theme
26
+ # @rbs theme: Riffer::Rig::Terminal::Theme
32
27
  # @rbs clock: singleton(Kernel)
33
28
  # @rbs return: void
34
- def initialize(io: $stdout, theme: Riffer::Rig::UI::Theme.for(io), clock: Kernel)
29
+ def initialize(io: $stdout, theme: Riffer::Rig::Terminal::Theme.for(io), clock: Kernel)
35
30
  @io = io
36
31
  @theme = theme
37
32
  @clock = clock
@@ -72,13 +67,13 @@ class Riffer::Rig::UI::Smoother
72
67
  self
73
68
  end
74
69
 
75
- # The write sits inside the mutex so a concurrent tick can't reorder a drain.
76
- #
77
70
  # @rbs return: void
78
71
  def tick
79
72
  @mutex.synchronize do
80
73
  return if @backlog.empty?
81
74
 
75
+ # The carry accrues sub-character releases so the effective rate can
76
+ # settle at the provider's throughput.
82
77
  @carry += [@backlog.length * BACKLOG_FRACTION_PER_TICK, MIN_CHARS_PER_TICK].max
83
78
  count = @carry.floor
84
79
  return if count.zero?
@@ -86,16 +81,12 @@ class Riffer::Rig::UI::Smoother
86
81
  @carry -= count
87
82
  chunk = @backlog.slice!(0, count) || ''
88
83
  @newline_pending = !chunk.end_with?("\n")
84
+ # Written inside the mutex so a concurrent drain can't reorder output.
89
85
  @io.print(chunk)
90
86
  @io.flush
91
87
  end
92
88
  end
93
89
 
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.
98
- #
99
90
  # @rbs return: void
100
91
  def drain
101
92
  @mutex.synchronize do
@@ -106,16 +97,17 @@ class Riffer::Rig::UI::Smoother
106
97
  end
107
98
  return unless @newline_pending
108
99
 
100
+ # Block spacing must not depend on whether the model ended its prose
101
+ # with a newline.
109
102
  @newline_pending = false
110
103
  @io.print("\n")
111
104
  @io.flush
112
105
  end
113
106
  end
114
107
 
115
- # Idempotent: the REPL's ensure path runs it even after a happy-path finish.
116
- #
117
108
  # @rbs return: void
118
109
  def finish
110
+ # Idempotent: the REPL's ensure path runs it even after a happy-path finish.
119
111
  thread = @thread
120
112
  if thread
121
113
  @stop = true
@@ -1,11 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # +enabled+ gates every escape so the same code path emits plain, escape-free
4
- # text when piped or tested.
5
- class Riffer::Rig::UI::Theme
3
+ class Riffer::Rig::Terminal::Theme
6
4
  # @rbs @enabled: bool
7
5
 
8
- # @rbs return: bool
9
6
  # @dynamic enabled
10
7
  attr_reader :enabled #: bool
11
8
 
@@ -16,9 +13,10 @@ class Riffer::Rig::UI::Theme
16
13
  end
17
14
 
18
15
  # @rbs io: IO
19
- # @rbs return: Riffer::Rig::UI::Theme
20
- def self.for(io)
21
- new(enabled: io.tty? && !ENV.key?('NO_COLOR'))
16
+ # @rbs no_color: bool
17
+ # @rbs return: Riffer::Rig::Terminal::Theme
18
+ def self.for(io, no_color: false)
19
+ new(enabled: io.tty? && !no_color)
22
20
  end
23
21
 
24
22
  # @rbs text: String
@@ -32,31 +30,31 @@ class Riffer::Rig::UI::Theme
32
30
 
33
31
  # @rbs text: String
34
32
  # @rbs return: String
35
- def pink(text) = paint(text, Riffer::Rig::UI::Palette::PINK)
33
+ def pink(text) = paint(text, Riffer::Rig::Terminal::Palette::PINK)
36
34
 
37
35
  # @rbs text: String
38
36
  # @rbs return: String
39
- def magenta(text) = paint(text, Riffer::Rig::UI::Palette::MAGENTA)
37
+ def magenta(text) = paint(text, Riffer::Rig::Terminal::Palette::MAGENTA)
40
38
 
41
39
  # @rbs text: String
42
40
  # @rbs return: String
43
- def purple(text) = paint(text, Riffer::Rig::UI::Palette::PURPLE)
41
+ def purple(text) = paint(text, Riffer::Rig::Terminal::Palette::PURPLE)
44
42
 
45
43
  # @rbs text: String
46
44
  # @rbs return: String
47
- def blue(text) = paint(text, Riffer::Rig::UI::Palette::BLUE)
45
+ def blue(text) = paint(text, Riffer::Rig::Terminal::Palette::BLUE)
48
46
 
49
47
  # @rbs text: String
50
48
  # @rbs return: String
51
- def cyan(text) = paint(text, Riffer::Rig::UI::Palette::CYAN)
49
+ def cyan(text) = paint(text, Riffer::Rig::Terminal::Palette::CYAN)
52
50
 
53
51
  # @rbs text: String
54
52
  # @rbs return: String
55
- def grey(text) = paint(text, Riffer::Rig::UI::Palette::GREY)
53
+ def grey(text) = paint(text, Riffer::Rig::Terminal::Palette::GREY)
56
54
 
57
55
  # @rbs text: String
58
56
  # @rbs return: String
59
- def red(text) = paint(text, Riffer::Rig::UI::Palette::RED)
57
+ def red(text) = paint(text, Riffer::Rig::Terminal::Palette::RED)
60
58
 
61
59
  # @rbs text: String
62
60
  # @rbs return: String
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Terminal
4
+ EXIT_COMMANDS = %w[/exit /quit].freeze #: Array[String]
5
+
6
+ SLASH_COMMAND = %r{\A/(\S+)\s*(.*)\z}m #: Regexp
7
+
8
+ SKILL_COMMAND_PREFIX = 'skill:'
9
+
10
+ EXIT_HINT = 'Press Ctrl-C again to exit.'
11
+
12
+ FAREWELL = 'see you on the next riff.'
13
+
14
+ # @rbs @input: IO
15
+ # @rbs @host: Riffer::Rig::Hosts::_Host
16
+ # @rbs @renderer: Riffer::Rig::Terminal::Renderer
17
+ # @rbs @animator: Riffer::Rig::Terminal::Animator
18
+ # @rbs @theme: Riffer::Rig::Terminal::Theme
19
+ # @rbs @version: String
20
+
21
+ # @rbs input: IO
22
+ # @rbs output: IO
23
+ # @rbs version: String
24
+ # @rbs no_color: bool
25
+ # @rbs return: Riffer::Rig::Terminal
26
+ def self.for(input:, output:, version:, no_color: false)
27
+ theme = Theme.for(output, no_color: no_color)
28
+ smoother = Smoother.new(io: output, theme: theme)
29
+ renderer = Renderer.new(io: output, theme: theme, smoother: smoother, cursor: Cursor.new(io: output, theme: theme))
30
+ animator = Animator.new(io: output, theme: theme)
31
+ host = Riffer::Rig::Terminal::Host.new(input: input, renderer: renderer, animator: animator)
32
+ new(input: input, host: host, renderer: renderer, animator: animator, theme: theme, version: version)
33
+ end
34
+
35
+ # @rbs input: IO
36
+ # @rbs host: Riffer::Rig::Hosts::_Host
37
+ # @rbs renderer: Riffer::Rig::Terminal::Renderer
38
+ # @rbs animator: Riffer::Rig::Terminal::Animator
39
+ # @rbs theme: Riffer::Rig::Terminal::Theme
40
+ # @rbs version: String
41
+ # @rbs return: void
42
+ def initialize(input:, host:, renderer:, animator:, theme:, version:)
43
+ @input = input
44
+ @host = host
45
+ @renderer = renderer
46
+ @animator = animator
47
+ @theme = theme
48
+ @version = version
49
+ end
50
+
51
+ # @rbs &build: (Riffer::Rig::Hosts::_Host) -> Riffer::Rig::Runtime
52
+ # @rbs return: Integer
53
+ def run(&build)
54
+ runtime = load(build)
55
+ return 1 unless runtime
56
+
57
+ begin
58
+ runtime.on_message { |message| render_tool_result(message) }
59
+ reveal_banner(runtime)
60
+ repl(runtime)
61
+ @renderer.notice(FAREWELL)
62
+ 0
63
+ ensure
64
+ runtime.close
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ # @rbs build: ^(Riffer::Rig::Hosts::_Host) -> Riffer::Rig::Runtime
71
+ # @rbs return: Riffer::Rig::Runtime?
72
+ def load(build)
73
+ build.call(@host)
74
+ rescue Riffer::Rig::Loader::ConfigurationError => e
75
+ @renderer.error(e.message)
76
+ nil
77
+ rescue Interrupt
78
+ @renderer.newline
79
+ nil
80
+ end
81
+
82
+ # @rbs runtime: Riffer::Rig::Runtime
83
+ # @rbs return: void
84
+ def reveal_banner(runtime)
85
+ skills = runtime.commands.count { |command| command.name.start_with?(SKILL_COMMAND_PREFIX) }
86
+ lines = Banner.lines(
87
+ @theme,
88
+ model: runtime.model,
89
+ cwd: runtime.cwd,
90
+ skills: skills.zero? ? 'none' : skills.to_s,
91
+ version: @version
92
+ )
93
+ @animator.reveal([lines])
94
+ end
95
+
96
+ # @rbs runtime: Riffer::Rig::Runtime
97
+ # @rbs return: void
98
+ def repl(runtime)
99
+ armed = false
100
+ loop do
101
+ case (line = read_line)
102
+ when nil then break
103
+ when :interrupt
104
+ break if armed
105
+
106
+ armed = true
107
+ @renderer.notice(EXIT_HINT)
108
+ else
109
+ armed = false
110
+ text = line.strip
111
+ break if EXIT_COMMANDS.include?(text)
112
+
113
+ dispatch(runtime, text) unless text.empty?
114
+ end
115
+ end
116
+ end
117
+
118
+ # @rbs return: String | :interrupt | nil
119
+ def read_line
120
+ @renderer.prompt
121
+ @input.gets
122
+ rescue Interrupt
123
+ :interrupt
124
+ end
125
+
126
+ # @rbs runtime: Riffer::Rig::Runtime
127
+ # @rbs text: String
128
+ # @rbs return: void
129
+ def dispatch(runtime, text)
130
+ command = SLASH_COMMAND.match(text)
131
+ turn(runtime) do |emit|
132
+ if command
133
+ runtime.run_command(command[1].to_s, command[2].to_s, &emit)
134
+ else
135
+ runtime.prompt(text, &emit)
136
+ end
137
+ end
138
+ end
139
+
140
+ # @rbs runtime: Riffer::Rig::Runtime
141
+ # @rbs &block: (^(::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event) -> void) -> void
142
+ # @rbs return: void
143
+ def turn(runtime)
144
+ # Runtime#cancel takes a Mutex, which Ruby refuses inside a trap handler,
145
+ # so the handler hands the cancel to a thread.
146
+ previous = Signal.trap('INT') { Thread.new { runtime.cancel } }
147
+ begin
148
+ @renderer.begin_turn
149
+ start_animator
150
+ yield(->(event) { render(runtime, event) })
151
+ rescue StandardError => e
152
+ @animator.stop
153
+ @renderer.error("Error: #{e.message}")
154
+ ensure
155
+ Signal.trap('INT', previous)
156
+ @animator.stop
157
+ @renderer.end_turn
158
+ end
159
+ end
160
+
161
+ # @rbs runtime: Riffer::Rig::Runtime
162
+ # @rbs event: ::Riffer::StreamEvents::Base | Riffer::Rig::Events::_Event
163
+ # @rbs return: void
164
+ def render(runtime, event)
165
+ case event
166
+ when Riffer::StreamEvents::ReasoningDelta
167
+ start_animator(:reasoning)
168
+ when Riffer::StreamEvents::ReasoningDone, Riffer::StreamEvents::TokenUsageDone
169
+ # Tool execution and the next model call emit no events, so the spinner
170
+ # comes straight back on to cover the silent stretch.
171
+ start_animator
172
+ when Riffer::StreamEvents::TextDelta, Riffer::StreamEvents::ToolCallDone, Riffer::StreamEvents::Interrupt
173
+ @animator.stop
174
+ @renderer.render(event)
175
+ when Riffer::StreamEvents::SkillActivation, Riffer::Rig::Events::SkillActivated
176
+ @animator.stop
177
+ @renderer.skill(event.name)
178
+ start_animator
179
+ when Riffer::Rig::Events::CommandOutput
180
+ @animator.stop
181
+ @renderer.notice(event.text)
182
+ when Riffer::Rig::Events::TurnEnd
183
+ @animator.stop
184
+ @renderer.usage(event.usage, runtime.tally)
185
+ end
186
+ end
187
+
188
+ # @rbs message: Riffer::Messages::Base
189
+ # @rbs return: void
190
+ def render_tool_result(message)
191
+ return unless message.is_a?(Riffer::Messages::Tool)
192
+
193
+ @animator.stop
194
+ @renderer.render_tool_result(message)
195
+ start_animator
196
+ end
197
+
198
+ # @rbs mode: Symbol
199
+ # @rbs return: void
200
+ def start_animator(mode = :neutral)
201
+ # The smoother's backlog may still be trickling out from streamed prose,
202
+ # and spinner frames drawn mid-drain would carve `\r…\e[K` through a
203
+ # half-printed sentence.
204
+ @renderer.drain
205
+ @animator.start(mode)
206
+ end
207
+ end