riffer-rig 0.13.0 → 0.15.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +19 -13
  4. data/lib/riffer/rig/cli/flags.rb +8 -1
  5. data/lib/riffer/rig/commands/reasoning.rb +46 -0
  6. data/lib/riffer/rig/env.rb +3 -1
  7. data/lib/riffer/rig/headless.rb +2 -0
  8. data/lib/riffer/rig/loader/options.rb +5 -1
  9. data/lib/riffer/rig/loader.rb +57 -3
  10. data/lib/riffer/rig/registrar.rb +3 -1
  11. data/lib/riffer/rig/runtime/snapshot.rb +7 -3
  12. data/lib/riffer/rig/runtime.rb +42 -10
  13. data/lib/riffer/rig/settings/document.rb +5 -5
  14. data/lib/riffer/rig/settings/file.rb +7 -0
  15. data/lib/riffer/rig/settings/model_options.rb +13 -33
  16. data/lib/riffer/rig/settings.rb +4 -1
  17. data/lib/riffer/rig/stores/entries.rb +3 -0
  18. data/lib/riffer/rig/stores/jsonl.rb +1 -0
  19. data/lib/riffer/rig/stores/reasoning_entry.rb +31 -0
  20. data/lib/riffer/rig/stores/recorder.rb +11 -4
  21. data/lib/riffer/rig/terminal/console.rb +13 -1
  22. data/lib/riffer/rig/terminal/count.rb +14 -0
  23. data/lib/riffer/rig/terminal/frame.rb +176 -51
  24. data/lib/riffer/rig/terminal/frame_prompter.rb +3 -1
  25. data/lib/riffer/rig/terminal/idle_ticker.rb +28 -0
  26. data/lib/riffer/rig/terminal/key_decoder.rb +65 -1
  27. data/lib/riffer/rig/terminal/key_pipeline.rb +8 -0
  28. data/lib/riffer/rig/terminal/line_editor.rb +10 -2
  29. data/lib/riffer/rig/terminal/loading.rb +71 -0
  30. data/lib/riffer/rig/terminal/repl.rb +19 -11
  31. data/lib/riffer/rig/terminal/spinner.rb +13 -0
  32. data/lib/riffer/rig/terminal/status_bar.rb +126 -0
  33. data/lib/riffer/rig/terminal/ticker.rb +21 -101
  34. data/lib/riffer/rig/terminal/ticker_display.rb +5 -9
  35. data/lib/riffer/rig/terminal/turn_report.rb +96 -14
  36. data/lib/riffer/rig/terminal/wrapper.rb +19 -7
  37. data/lib/riffer/rig/terminal.rb +59 -20
  38. data/lib/riffer/rig/tools/bash.rb +9 -2
  39. data/lib/riffer/rig/version.rb +1 -1
  40. data/sig/generated/riffer/rig/cli/flags.rbs +5 -2
  41. data/sig/generated/riffer/rig/commands/reasoning.rbs +16 -0
  42. data/sig/generated/riffer/rig/env.rbs +3 -1
  43. data/sig/generated/riffer/rig/loader/options.rbs +5 -2
  44. data/sig/generated/riffer/rig/loader.rbs +30 -0
  45. data/sig/generated/riffer/rig/runtime/snapshot.rbs +5 -2
  46. data/sig/generated/riffer/rig/runtime.rbs +22 -6
  47. data/sig/generated/riffer/rig/settings/document.rbs +2 -2
  48. data/sig/generated/riffer/rig/settings/file.rbs +5 -0
  49. data/sig/generated/riffer/rig/settings/model_options.rbs +8 -14
  50. data/sig/generated/riffer/rig/stores/reasoning_entry.rbs +23 -0
  51. data/sig/generated/riffer/rig/stores/recorder.rbs +8 -4
  52. data/sig/generated/riffer/rig/terminal/console.rbs +7 -1
  53. data/sig/generated/riffer/rig/terminal/count.rbs +7 -0
  54. data/sig/generated/riffer/rig/terminal/frame.rbs +75 -12
  55. data/sig/generated/riffer/rig/terminal/idle_ticker.rbs +21 -0
  56. data/sig/generated/riffer/rig/terminal/key_decoder.rbs +29 -0
  57. data/sig/generated/riffer/rig/terminal/key_pipeline.rbs +6 -0
  58. data/sig/generated/riffer/rig/terminal/line_editor.rbs +3 -0
  59. data/sig/generated/riffer/rig/terminal/loading.rbs +42 -0
  60. data/sig/generated/riffer/rig/terminal/repl.rbs +5 -3
  61. data/sig/generated/riffer/rig/terminal/spinner.rbs +9 -0
  62. data/sig/generated/riffer/rig/terminal/status_bar.rbs +88 -0
  63. data/sig/generated/riffer/rig/terminal/ticker.rbs +14 -60
  64. data/sig/generated/riffer/rig/terminal/ticker_display.rbs +3 -6
  65. data/sig/generated/riffer/rig/terminal/turn_report.rbs +48 -5
  66. data/sig/generated/riffer/rig/terminal/wrapper.rbs +8 -1
  67. data/sig/generated/riffer/rig/terminal.rbs +23 -0
  68. data/sig/manual/riffer/rig/{terminal/equalizer.rbs → commands/reasoning.rbs} +2 -2
  69. data/sig/manual/riffer/rig/stores.rbs +2 -2
  70. data/sig/manual/riffer/rig/terminal/count.rbs +6 -0
  71. data/sig/manual/riffer/rig/terminal/spinner.rbs +6 -0
  72. data/sig/manual/riffer/rig/terminal/status_bar.rbs +6 -0
  73. metadata +21 -8
  74. data/lib/riffer/rig/settings/pricing.rb +0 -40
  75. data/lib/riffer/rig/terminal/equalizer.rb +0 -20
  76. data/sig/generated/riffer/rig/settings/pricing.rbs +0 -31
  77. data/sig/generated/riffer/rig/terminal/equalizer.rbs +0 -12
@@ -3,12 +3,6 @@
3
3
  module Riffer::Rig::Settings::ModelOptions
4
4
  extend self
5
5
 
6
- REASONING_LEVELS_BY_PROVIDER = {
7
- 'anthropic' => %w[low medium high xhigh max].freeze,
8
- 'openai' => %w[low medium high xhigh].freeze,
9
- 'openrouter' => %w[low medium high xhigh].freeze
10
- }.freeze #: Hash[String, Array[String]]
11
-
12
6
  MODEL_STRING = %r{\A(?<provider>[^/\s]+)/\S+\z} #: Regexp
13
7
 
14
8
  # @rbs model: String
@@ -26,39 +20,25 @@ module Riffer::Rig::Settings::ModelOptions
26
20
  "#{model} is not a provider/name model string; the provider is one of: #{Riffer::Rig::Providers.list}"
27
21
  end
28
22
 
29
- # @rbs model: String
30
- # @rbs reasoning: String?
31
- # @rbs return: Hash[Symbol, untyped]
32
- def model_options(model, reasoning)
33
- provider = provider_for(model)
34
- levels = (provider && REASONING_LEVELS_BY_PROVIDER[provider]) || []
35
- level = reasoning if levels.include?(reasoning)
36
- base_options(provider).merge(reasoning_options(level, provider))
23
+ # @rbs level: String?
24
+ # @rbs return: String?
25
+ def reasoning_level(level)
26
+ level if level && Riffer::Agent::Config::REASONING_LEVELS.include?(level.to_sym)
37
27
  end
38
28
 
39
- private
40
-
41
- # @rbs provider: String?
42
- # @rbs return: Hash[Symbol, untyped]
43
- def base_options(provider)
44
- return { cache_control: { type: :ephemeral } } if provider == 'anthropic'
29
+ # @rbs level: String
30
+ # @rbs return: String?
31
+ def reasoning_rejection(level)
32
+ return if reasoning_level(level)
45
33
 
46
- {}
34
+ "#{level} is not a reasoning level; riffer accepts: #{Riffer::Agent::Config::REASONING_LEVELS.join(' ')}"
47
35
  end
48
36
 
49
- # @rbs level: String?
50
- # @rbs provider: String?
37
+ # @rbs model: String
51
38
  # @rbs return: Hash[Symbol, untyped]
52
- def reasoning_options(level, provider)
53
- return {} unless level
39
+ def model_options(model)
40
+ return { cache_control: { type: :ephemeral } } if provider_for(model) == 'anthropic'
54
41
 
55
- case provider
56
- when 'anthropic'
57
- { output_config: { effort: level } }
58
- when 'openai', 'openrouter'
59
- { reasoning: level }
60
- else
61
- {}
62
- end
42
+ {}
63
43
  end
64
44
  end
@@ -15,7 +15,10 @@ module Riffer::Rig::Settings
15
15
  if home_mcp.is_a?(Hash) && project_mcp.is_a?(Hash)
16
16
  merged = merged.merge(mcp: Riffer::Rig::Mcp.merge(home_mcp, project_mcp))
17
17
  end
18
- disabled = [home, project].flat_map { |scope| Document.new(scope).disabled }.uniq
18
+ documents = [home, project].map { |scope| Document.new(scope) }
19
+ disabled = documents.flat_map(&:disabled).uniq
20
+ catalog_files = documents.flat_map(&:catalog_files).uniq
21
+ merged = merged.merge(catalog_files: catalog_files) if catalog_files.any?
19
22
  return merged if disabled.empty?
20
23
 
21
24
  extensions = merged[:extensions].is_a?(Hash) ? merged[:extensions] : {} #: Hash[Symbol, untyped]
@@ -17,6 +17,9 @@ module Riffer::Rig::Stores::Entries
17
17
  id: id,
18
18
  messages: messages(entries),
19
19
  model: entries.filter_map { |entry| entry.model if entry.is_a?(Riffer::Rig::Stores::ModelEntry) }.last,
20
+ reasoning: entries.filter_map do |entry|
21
+ entry.reasoning if entry.is_a?(Riffer::Rig::Stores::ReasoningEntry)
22
+ end.last,
20
23
  skills: entries.filter_map { |entry| entry.skill if entry.is_a?(Riffer::Rig::Stores::SkillEntry) }
21
24
  }
22
25
  end
@@ -10,6 +10,7 @@ class Riffer::Rig::Stores::JSONL
10
10
  Riffer::Rig::Stores::Header.type => Riffer::Rig::Stores::Header,
11
11
  Riffer::Rig::Stores::MessageEntry.type => Riffer::Rig::Stores::MessageEntry,
12
12
  Riffer::Rig::Stores::ModelEntry.type => Riffer::Rig::Stores::ModelEntry,
13
+ Riffer::Rig::Stores::ReasoningEntry.type => Riffer::Rig::Stores::ReasoningEntry,
13
14
  Riffer::Rig::Stores::SkillEntry.type => Riffer::Rig::Stores::SkillEntry
14
15
  }.freeze #: Hash[String, Riffer::Rig::Stores::entry_type]
15
16
 
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Stores::ReasoningEntry
4
+ extend Riffer::Rig::Support::TypedValue::Schema #[String]
5
+ include Riffer::Rig::Support::TypedValue #[String]
6
+ include Riffer::Rig::Stores::Stamped
7
+
8
+ typed_value 'reasoning', :reasoning, :recorded_at
9
+
10
+ # @dynamic reasoning, recorded_at
11
+ attr_reader :reasoning #: String
12
+ attr_reader :recorded_at #: String?
13
+
14
+ # @rbs hash: Hash[Symbol, untyped]
15
+ # @rbs return: ::Riffer::Rig::Stores::ReasoningEntry?
16
+ def self.from_hash(hash)
17
+ reasoning = hash[:reasoning]
18
+ return nil unless reasoning.is_a?(String)
19
+
20
+ new(reasoning: reasoning, recorded_at: Riffer::Rig::Stores::Timestamp.dump(Riffer::Rig::Stores::Timestamp.load(hash[:recorded_at])))
21
+ end
22
+
23
+ # @rbs reasoning: String
24
+ # @rbs recorded_at: String?
25
+ # @rbs return: void
26
+ def initialize(reasoning:, recorded_at: nil)
27
+ @reasoning = reasoning
28
+ @recorded_at = recorded_at
29
+ freeze
30
+ end
31
+ end
@@ -15,10 +15,10 @@ class Riffer::Rig::Stores::Recorder
15
15
 
16
16
  # Records one Loader-built session into the store: the header on the first
17
17
  # message (its title needs the first prompt), one entry per message, one per
18
- # model switch and one per skill activation. Entries that land before the
19
- # first message (a /model before the first prompt) wait for the header. A
20
- # resumed session appends to the file its first run wrote, so it rewrites
21
- # no header.
18
+ # model switch, one per reasoning switch and one per skill activation. Entries
19
+ # that land before the first message (a /model before the first prompt) wait
20
+ # for the header. A resumed session appends to the file its first run wrote,
21
+ # so it rewrites no header.
22
22
  # @rbs store: Riffer::Rig::Stores::_Store
23
23
  # @rbs resumed: bool
24
24
  # @rbs clock: singleton(Time)
@@ -40,6 +40,7 @@ class Riffer::Rig::Stores::Recorder
40
40
  @last_recorded_at = last_recorded_at(runtime.id) if @resumed
41
41
  runtime.on_message { |message| message(message) }
42
42
  runtime.on_model_change { |model| switched(model) }
43
+ runtime.on_reasoning_change { |level| switched_reasoning(level) }
43
44
  end
44
45
 
45
46
  # @rbs event: Riffer::StreamEvents::SkillActivation
@@ -63,6 +64,12 @@ class Riffer::Rig::Stores::Recorder
63
64
  record(Riffer::Rig::Stores::ModelEntry.new(model: model, recorded_at: Riffer::Rig::Stores::Timestamp.dump(stamp)))
64
65
  end
65
66
 
67
+ # @rbs level: String
68
+ # @rbs return: void
69
+ def switched_reasoning(level)
70
+ record(Riffer::Rig::Stores::ReasoningEntry.new(reasoning: level, recorded_at: Riffer::Rig::Stores::Timestamp.dump(stamp)))
71
+ end
72
+
66
73
  # The extension rides the build as its last extension, so its :stream hook
67
74
  # sees the model's skill activations. Per build, unlike the extensions in
68
75
  # the process registry: the hook closes over this build's recorder.
@@ -3,25 +3,37 @@
3
3
  class Riffer::Rig::Terminal::Console
4
4
  # @rbs @frame: Riffer::Rig::Terminal::Frame
5
5
  # @rbs @pipeline: Riffer::Rig::Terminal::KeyPipeline
6
+ # @rbs @loading: Riffer::Rig::Terminal::Loading
7
+ # @rbs @idle: Riffer::Rig::Terminal::IdleTicker
6
8
 
7
9
  # @rbs frame: Riffer::Rig::Terminal::Frame
8
10
  # @rbs pipeline: Riffer::Rig::Terminal::KeyPipeline
11
+ # @rbs loading: Riffer::Rig::Terminal::Loading
12
+ # @rbs idle: Riffer::Rig::Terminal::IdleTicker
9
13
  # @rbs return: void
10
- def initialize(frame:, pipeline:)
14
+ def initialize(frame:, pipeline:, loading:, idle:)
11
15
  @frame = frame
12
16
  @pipeline = pipeline
17
+ @loading = loading
18
+ @idle = idle
13
19
  end
14
20
 
15
21
  # @rbs return: void
16
22
  def start
23
+ # The first tick pushes the animated line before the frame draws, so the
24
+ # loading row is animated from its first write.
25
+ @loading.start
17
26
  @frame.start
27
+ @idle.start
18
28
  @pipeline.start
19
29
  end
20
30
 
21
31
  # @rbs return: void
22
32
  def stop
23
33
  @pipeline.stop
34
+ @idle.stop
24
35
  @frame.stop
36
+ @loading.stop
25
37
  end
26
38
 
27
39
  # @rbs *chunks: String
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Riffer::Rig::Terminal::Count
4
+ extend self
5
+
6
+ # @rbs amount: Integer
7
+ # @rbs return: String
8
+ def call(amount)
9
+ return amount.to_s if amount < 1_000
10
+ return "#{format('%g', amount.fdiv(1_000).floor(amount < 100_000 ? 1 : 0))}k" if amount < 1_000_000
11
+
12
+ "#{format('%g', amount.fdiv(1_000_000).floor(amount < 100_000_000 ? 1 : 0))}M"
13
+ end
14
+ end
@@ -7,15 +7,15 @@ class Riffer::Rig::Terminal::Frame
7
7
 
8
8
  PROMPT_COLUMNS = 2 #: Integer
9
9
 
10
+ INDENT = ' ' * PROMPT_COLUMNS #: String
11
+
10
12
  STATUS_INDENT = ' ' #: String
11
13
 
12
14
  # The first column is the cursor's own cell when it rests at the end of the
13
15
  # text.
14
16
  QUEUED_GAP = ' ' #: String
15
17
 
16
- JOBS_SEPARATOR = ' · ' #: String
17
-
18
- FOOTER_ROWS = 3 #: Integer
18
+ INPUT_ROWS = 8 #: Integer
19
19
 
20
20
  PEEK_ROWS = 8 #: Integer
21
21
 
@@ -28,8 +28,15 @@ class Riffer::Rig::Terminal::Frame
28
28
  # Sent from the top row the frame drew, so this wipes all of it.
29
29
  ERASE = "\r\e[J" #: String
30
30
 
31
+ LOADING = 'loading…' #: String
32
+
31
33
  SPACER = "\r\n" #: String
32
34
 
35
+ # Marks the cursor's cell in the text handed to the wrapper, so the wrapped
36
+ # row and column can be read back off it: a private-use code point, which no
37
+ # terminal draws.
38
+ CURSOR = "\uE000" #: String
39
+
33
40
  # @rbs @io: IO
34
41
  # @rbs @theme: Riffer::Rig::Terminal::Theme
35
42
  # @rbs @editor: Riffer::Rig::Terminal::LineEditor
@@ -43,6 +50,10 @@ class Riffer::Rig::Terminal::Frame
43
50
  # @rbs @height: Integer
44
51
  # @rbs @width: Integer
45
52
  # @rbs @active: bool
53
+ # @rbs @loading: bool
54
+ # @rbs @loading_line: String
55
+ # @rbs @tally: (^() -> Riffer::Providers::TokenUsage?)?
56
+ # @rbs @now: ^() -> Time
46
57
  # @rbs @mutex: Mutex
47
58
  # @rbs @previous_resize: Signal::trap_command
48
59
 
@@ -63,8 +74,10 @@ class Riffer::Rig::Terminal::Frame
63
74
  # @rbs io: IO
64
75
  # @rbs theme: Riffer::Rig::Terminal::Theme
65
76
  # @rbs editor: Riffer::Rig::Terminal::LineEditor
77
+ # @rbs loading: bool
78
+ # @rbs now: ^() -> Time
66
79
  # @rbs return: void
67
- def initialize(io:, theme:, editor: Riffer::Rig::Terminal::LineEditor.new)
80
+ def initialize(io:, theme:, editor: Riffer::Rig::Terminal::LineEditor.new, loading: false, now: -> { Time.now })
68
81
  @io = io
69
82
  @theme = theme
70
83
  @editor = editor
@@ -78,6 +91,10 @@ class Riffer::Rig::Terminal::Frame
78
91
  @height = 0
79
92
  @width = 0
80
93
  @active = false
94
+ @loading = loading
95
+ @loading_line = LOADING
96
+ @tally = nil
97
+ @now = now
81
98
  @mutex = Mutex.new
82
99
  @previous_resize = nil
83
100
  end
@@ -101,6 +118,9 @@ class Riffer::Rig::Terminal::Frame
101
118
  def append(content)
102
119
  lines = content.end_with?("\n") ? content : "#{content}\n"
103
120
  @mutex.synchronize do
121
+ # The first content and the footer it draws go out in this one write, so
122
+ # this is where the loading screen ends.
123
+ @loading = false
104
124
  # The key pipeline's raw mode turns off output post-processing, so a
105
125
  # bare line feed would not return the carriage.
106
126
  @active ? draw("#{erase}#{lines.gsub(/\r?\n/, "\r\n")}") : write(lines)
@@ -136,6 +156,22 @@ class Riffer::Rig::Terminal::Frame
136
156
  end
137
157
  end
138
158
 
159
+ # @rbs line: String
160
+ # @rbs return: void
161
+ def loading(line)
162
+ @mutex.synchronize do
163
+ @loading_line = line
164
+ # A tick still in flight once the banner drew would otherwise redraw the
165
+ # footer under it.
166
+ redraw if @loading
167
+ end
168
+ end
169
+
170
+ # @rbs return: bool
171
+ def loading?
172
+ @mutex.synchronize { @loading }
173
+ end
174
+
139
175
  # @rbs hint: String?
140
176
  # @rbs return: void
141
177
  def hint(hint)
@@ -163,9 +199,30 @@ class Riffer::Rig::Terminal::Frame
163
199
  end
164
200
  end
165
201
 
202
+ # @rbs tally: ^() -> Riffer::Providers::TokenUsage?
203
+ # @rbs return: void
204
+ def session(tally:)
205
+ @mutex.synchronize do
206
+ @tally = tally
207
+ redraw
208
+ end
209
+ end
210
+
211
+ # @rbs return: void
212
+ def idle_tick
213
+ @mutex.synchronize do
214
+ redraw if idle_bar_showing?
215
+ end
216
+ end
217
+
166
218
  # @rbs return: Integer
167
219
  def status_room
168
- @mutex.synchronize { [status_columns(@width - 1), 0].max }
220
+ @mutex.synchronize { [(@width - 1) - STATUS_INDENT.size, 0].max }
221
+ end
222
+
223
+ # @rbs return: Integer
224
+ def job_count
225
+ @mutex.synchronize { @jobs }
169
226
  end
170
227
 
171
228
  # @rbs content: String
@@ -183,6 +240,9 @@ class Riffer::Rig::Terminal::Frame
183
240
  return unless @active
184
241
 
185
242
  @active = false
243
+ # Ends the loading animation's thread, which stops itself from its next
244
+ # tick once the row it draws is gone.
245
+ @loading = false
186
246
  write(erase)
187
247
  end
188
248
  Signal.trap('WINCH', @previous_resize)
@@ -216,13 +276,26 @@ class Riffer::Rig::Terminal::Frame
216
276
  # @rbs before: String
217
277
  # @rbs return: void
218
278
  def draw(before = '')
279
+ return draw_loading(before) if @loading
280
+
281
+ shown, row, column = input_rows
219
282
  # A row scrolled off the top of the screen could never be erased again.
220
- above = [@height - FOOTER_ROWS, 0].max
283
+ # The status sits under the input and the spacer above everything drawn.
284
+ above = [@height - shown.size - 3, 0].max
221
285
  palette = palette_rows([above, PALETTE_ROWS].min)
222
- spacer = SPACER if @height >= FOOTER_ROWS
223
- rows = [*spacer, *peek_rows([above - palette.size, PEEK_ROWS].min), *palette]
224
- write("#{before}#{rows.join}#{footer}")
225
- @rows_above = rows.size
286
+ spacer = SPACER if @height >= shown.size + 3
287
+ lead = [*spacer, *peek_rows([above - palette.size, PEEK_ROWS].min), *palette]
288
+ write("#{before}#{lead.join}#{footer(shown, row, column)}")
289
+ @rows_above = lead.size + row
290
+ end
291
+
292
+ # The cursor stays on the row the loading line was written to, so the erase
293
+ # that replaces it wipes it like the footer's own row.
294
+ # @rbs before: String
295
+ # @rbs return: void
296
+ def draw_loading(before)
297
+ line = "#{STATUS_INDENT}#{@theme.dim(@loading_line)}"
298
+ write("#{before}#{Riffer::Rig::Terminal::Columns.head(line, @width - 1)}")
226
299
  end
227
300
 
228
301
  # @rbs limit: Integer
@@ -263,36 +336,89 @@ class Riffer::Rig::Terminal::Frame
263
336
  "#{Riffer::Rig::Terminal::Columns.head("#{label}#{DESCRIPTION_GAP}#{@theme.dim(about)}", @width - 1)}\r\n"
264
337
  end
265
338
 
266
- # @rbs return: String
267
- def footer
339
+ # The input rows as drawn, the row the cursor sits on among them, and its
340
+ # column on that row.
341
+ # @rbs return: [Array[String], Integer, Integer]
342
+ def input_rows
343
+ return viewport if typing < 1
344
+
268
345
  # The last column stays empty: a glyph written there leaves the cursor
269
346
  # in a pending wrap, which terminals resolve differently.
270
- room = @width - 1
271
- typing = [room - PROMPT_COLUMNS, 0].max
272
- before = Riffer::Rig::Terminal::Columns.tail(@editor.printable_before, typing)
273
- after = Riffer::Rig::Terminal::Columns.head(
274
- @editor.printable_after,
275
- typing - Riffer::Rig::Terminal::Columns.width(before)
276
- )
277
- typed = "#{before}#{after}"
347
+ # The echo shows the text Enter will send, so its spaces are never folded.
348
+ wrapper = Riffer::Rig::Terminal::Wrapper.new(width: @width - 1, indent: PROMPT_COLUMNS, verbatim: true)
349
+ wrapped = "#{wrapper << "#{@editor.printable_before}#{CURSOR}#{@editor.printable_after}"}#{wrapper.flush}"
350
+ rows = wrapped.lines(chomp: true)
351
+ row = rows.index { |line| line.include?(CURSOR) } || 0
352
+ prefix = rows.fetch(row).partition(CURSOR).first.delete_prefix(INDENT)
353
+ column = PROMPT_COLUMNS + Riffer::Rig::Terminal::Columns.width(prefix)
354
+
355
+ first = (rows.size - input_cap).clamp(0, row)
356
+ shown = rows.drop(first).take(input_cap).map do |line|
357
+ line.delete_prefix(INDENT).sub(CURSOR, '')
358
+ end
359
+ row -= first
360
+ shown[row] = ghosted(shown.fetch(row))
361
+ last = shown.last
362
+ chip = chip(typing, Riffer::Rig::Terminal::Columns.width(last))
363
+ # The chip rides flush right, so a chip appearing pads the row out to it
364
+ # rather than moving the prompt or the text.
365
+ shown[-1] = chip.empty? ? last : "#{last}#{' ' * (typing - Riffer::Rig::Terminal::Columns.width(last) - Riffer::Rig::Terminal::Columns.width(chip))}#{chip}"
366
+ prompted = shown.each_with_index.map do |line, offset|
367
+ if (first + offset).zero?
368
+ "#{@theme.pink(PROMPT)} #{line}"
369
+ else
370
+ "#{INDENT}#{line}"
371
+ end
372
+ end
373
+ [prompted, row, column]
374
+ end
375
+
376
+ # @rbs return: Integer
377
+ def input_cap
378
+ (@height - 1).clamp(1, INPUT_ROWS)
379
+ end
380
+
381
+ # @rbs line: String
382
+ # @rbs return: String
383
+ def ghosted(line)
278
384
  ghost = Riffer::Rig::Terminal::Columns.head(
279
385
  @completion&.ghost.to_s,
280
- typing - Riffer::Rig::Terminal::Columns.width(typed)
386
+ typing - Riffer::Rig::Terminal::Columns.width(line)
281
387
  )
282
- shown = "#{typed}#{@theme.dim(ghost) unless ghost.empty?}"
283
- spare = typing - Riffer::Rig::Terminal::Columns.width(shown)
284
- input = Riffer::Rig::Terminal::Columns.head("#{@theme.pink(PROMPT)} #{shown}#{chip(spare)}", room)
285
- column = [PROMPT_COLUMNS + Riffer::Rig::Terminal::Columns.width(before), room].min
286
- "#{input}\r\n#{status_line(room)}\e[1A\e[#{column + 1}G"
388
+ "#{line}#{@theme.dim(ghost) unless ghost.empty?}"
287
389
  end
288
390
 
289
- # @rbs room: Integer
391
+ # @rbs return: Integer
392
+ def typing
393
+ @width - 1 - PROMPT_COLUMNS
394
+ end
395
+
396
+ # The wrapped rows need a column for the prompt and one of text; on a
397
+ # narrower terminal the input stays the one-row viewport, cut to fit.
398
+ # @rbs return: [Array[String], Integer, Integer]
399
+ def viewport
400
+ room = @width - 1
401
+ [[Riffer::Rig::Terminal::Columns.head("#{@theme.pink(PROMPT)} ", room)], 0, room]
402
+ end
403
+
404
+ # @rbs rows: Array[String]
405
+ # @rbs row: Integer
406
+ # @rbs column: Integer
407
+ # @rbs return: String
408
+ def footer(rows, row, column)
409
+ "#{rows.join("\r\n")}\r\n\r\n#{status_line(@width - 1)}\e[#{rows.size - row + 1}A\e[#{column + 1}G"
410
+ end
411
+
412
+ # @rbs typing: Integer
413
+ # @rbs text: Integer
290
414
  # @rbs return: String
291
- def chip(room)
415
+ def chip(typing, text)
416
+ return '' if @queued.zero?
417
+
292
418
  label = "· #{@queued} queued"
293
419
  # A count cut short would read as a smaller queue, so the chip is drawn
294
- # whole or not at all.
295
- return '' if @queued.zero? || QUEUED_GAP.size + label.size > room
420
+ # whole or not at all — and only where it leaves the text alone.
421
+ return '' if QUEUED_GAP.size + label.size + text > typing
296
422
 
297
423
  "#{QUEUED_GAP}#{@theme.dim(label)}"
298
424
  end
@@ -302,32 +428,31 @@ class Riffer::Rig::Terminal::Frame
302
428
  def status_line(room)
303
429
  # A running ticker sets the status on every tick, so a hint outranks it
304
430
  # rather than being drawn over.
305
- shown = @hint || @status
306
- label = counter(room)
307
- return Riffer::Rig::Terminal::Columns.head("#{STATUS_INDENT}#{shown}", room) unless label
308
-
309
- # The counter is the only sign that a job is still running, so the status
310
- # is the one cut to make room.
311
- status = Riffer::Rig::Terminal::Columns.head(shown, status_columns(room))
312
- # A status cut to nothing still carries its styles.
313
- trailer = Riffer::Rig::Terminal::Columns.width(status).zero? ? label : "#{JOBS_SEPARATOR}#{label}"
314
- "#{STATUS_INDENT}#{status}#{@theme.dim(trailer)}"
431
+ shown = idle_bar_showing? ? idle_bar([room - STATUS_INDENT.size, 0].max) : (@hint || @status)
432
+ Riffer::Rig::Terminal::Columns.head("#{STATUS_INDENT}#{shown}", room)
315
433
  end
316
434
 
317
- # @rbs room: Integer
318
- # @rbs return: Integer
319
- def status_columns(room)
320
- label = counter(room)
321
- room - STATUS_INDENT.size - (label ? JOBS_SEPARATOR.size + label.size : 0)
435
+ # @rbs return: bool
436
+ def idle_bar_showing?
437
+ !@loading && @hint.nil? && @status.empty? && !@tally.nil?
322
438
  end
323
439
 
324
440
  # @rbs room: Integer
325
- # @rbs return: String?
326
- def counter(room)
327
- label = "#{@jobs} #{@jobs == 1 ? 'job' : 'jobs'} running"
328
- # A count cut short would read as fewer jobs, so the counter is drawn
329
- # whole or not at all.
330
- label unless @jobs.zero? || STATUS_INDENT.size + label.size > room
441
+ # @rbs return: String
442
+ def idle_bar(room)
443
+ tally = @tally
444
+ return '' unless tally
445
+
446
+ usage = tally.call
447
+ Riffer::Rig::Terminal::StatusBar.idle(
448
+ clock: @now.call.strftime('%H:%M'),
449
+ up_tokens: usage&.input_tokens || 0,
450
+ down_tokens: usage&.output_tokens || 0,
451
+ cost: usage&.cost || 0.0,
452
+ jobs: @jobs,
453
+ theme: @theme,
454
+ room: room
455
+ )
331
456
  end
332
457
 
333
458
  # @rbs bytes: String
@@ -46,7 +46,9 @@ class Riffer::Rig::Terminal::FramePrompter
46
46
  # Raw mode delivers Ctrl-C as a key instead of the signal a blocking
47
47
  # read is interrupted by.
48
48
  raise Interrupt if key.name == :ctrl_c
49
- return editor if key.name == :enter
49
+ # A prompt answer is one value, so every Enter-flavoured key sends it:
50
+ # the editor's newline keys exist for the prompt line, not for answers.
51
+ return editor if key.name == :enter || Riffer::Rig::Terminal::LineEditor::NEWLINE_KEYS.include?(key.name)
50
52
 
51
53
  editor = editor.press(key)
52
54
  @frame.input(editor) unless secret
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Riffer::Rig::Terminal::IdleTicker
4
+ TICK_SECONDS = 1.0 #: Float
5
+
6
+ # @rbs @footer: Riffer::Rig::Terminal::Frame
7
+ # @rbs @halt: Thread::Queue[true]
8
+ # @rbs @thread: Thread?
9
+
10
+ # @rbs footer: Riffer::Rig::Terminal::Frame
11
+ # @rbs return: void
12
+ def initialize(footer:)
13
+ @footer = footer
14
+ @halt = Queue.new
15
+ @thread = nil
16
+ end
17
+
18
+ # @rbs return: void
19
+ def start
20
+ @thread = Thread.new { @footer.idle_tick until @halt.pop(timeout: TICK_SECONDS) }
21
+ end
22
+
23
+ # @rbs return: void
24
+ def stop
25
+ @halt << true
26
+ @thread&.join
27
+ end
28
+ end