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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +19 -13
- data/lib/riffer/rig/cli/flags.rb +8 -1
- data/lib/riffer/rig/commands/reasoning.rb +46 -0
- data/lib/riffer/rig/env.rb +3 -1
- data/lib/riffer/rig/headless.rb +2 -0
- data/lib/riffer/rig/loader/options.rb +5 -1
- data/lib/riffer/rig/loader.rb +57 -3
- data/lib/riffer/rig/registrar.rb +3 -1
- data/lib/riffer/rig/runtime/snapshot.rb +7 -3
- data/lib/riffer/rig/runtime.rb +42 -10
- data/lib/riffer/rig/settings/document.rb +5 -5
- data/lib/riffer/rig/settings/file.rb +7 -0
- data/lib/riffer/rig/settings/model_options.rb +13 -33
- data/lib/riffer/rig/settings.rb +4 -1
- data/lib/riffer/rig/stores/entries.rb +3 -0
- data/lib/riffer/rig/stores/jsonl.rb +1 -0
- data/lib/riffer/rig/stores/reasoning_entry.rb +31 -0
- data/lib/riffer/rig/stores/recorder.rb +11 -4
- data/lib/riffer/rig/terminal/console.rb +13 -1
- data/lib/riffer/rig/terminal/count.rb +14 -0
- data/lib/riffer/rig/terminal/frame.rb +176 -51
- data/lib/riffer/rig/terminal/frame_prompter.rb +3 -1
- data/lib/riffer/rig/terminal/idle_ticker.rb +28 -0
- data/lib/riffer/rig/terminal/key_decoder.rb +65 -1
- data/lib/riffer/rig/terminal/key_pipeline.rb +8 -0
- data/lib/riffer/rig/terminal/line_editor.rb +10 -2
- data/lib/riffer/rig/terminal/loading.rb +71 -0
- data/lib/riffer/rig/terminal/repl.rb +19 -11
- data/lib/riffer/rig/terminal/spinner.rb +13 -0
- data/lib/riffer/rig/terminal/status_bar.rb +126 -0
- data/lib/riffer/rig/terminal/ticker.rb +21 -101
- data/lib/riffer/rig/terminal/ticker_display.rb +5 -9
- data/lib/riffer/rig/terminal/turn_report.rb +96 -14
- data/lib/riffer/rig/terminal/wrapper.rb +19 -7
- data/lib/riffer/rig/terminal.rb +59 -20
- data/lib/riffer/rig/tools/bash.rb +9 -2
- data/lib/riffer/rig/version.rb +1 -1
- data/sig/generated/riffer/rig/cli/flags.rbs +5 -2
- data/sig/generated/riffer/rig/commands/reasoning.rbs +16 -0
- data/sig/generated/riffer/rig/env.rbs +3 -1
- data/sig/generated/riffer/rig/loader/options.rbs +5 -2
- data/sig/generated/riffer/rig/loader.rbs +30 -0
- data/sig/generated/riffer/rig/runtime/snapshot.rbs +5 -2
- data/sig/generated/riffer/rig/runtime.rbs +22 -6
- data/sig/generated/riffer/rig/settings/document.rbs +2 -2
- data/sig/generated/riffer/rig/settings/file.rbs +5 -0
- data/sig/generated/riffer/rig/settings/model_options.rbs +8 -14
- data/sig/generated/riffer/rig/stores/reasoning_entry.rbs +23 -0
- data/sig/generated/riffer/rig/stores/recorder.rbs +8 -4
- data/sig/generated/riffer/rig/terminal/console.rbs +7 -1
- data/sig/generated/riffer/rig/terminal/count.rbs +7 -0
- data/sig/generated/riffer/rig/terminal/frame.rbs +75 -12
- data/sig/generated/riffer/rig/terminal/idle_ticker.rbs +21 -0
- data/sig/generated/riffer/rig/terminal/key_decoder.rbs +29 -0
- data/sig/generated/riffer/rig/terminal/key_pipeline.rbs +6 -0
- data/sig/generated/riffer/rig/terminal/line_editor.rbs +3 -0
- data/sig/generated/riffer/rig/terminal/loading.rbs +42 -0
- data/sig/generated/riffer/rig/terminal/repl.rbs +5 -3
- data/sig/generated/riffer/rig/terminal/spinner.rbs +9 -0
- data/sig/generated/riffer/rig/terminal/status_bar.rbs +88 -0
- data/sig/generated/riffer/rig/terminal/ticker.rbs +14 -60
- data/sig/generated/riffer/rig/terminal/ticker_display.rbs +3 -6
- data/sig/generated/riffer/rig/terminal/turn_report.rbs +48 -5
- data/sig/generated/riffer/rig/terminal/wrapper.rbs +8 -1
- data/sig/generated/riffer/rig/terminal.rbs +23 -0
- data/sig/manual/riffer/rig/{terminal/equalizer.rbs → commands/reasoning.rbs} +2 -2
- data/sig/manual/riffer/rig/stores.rbs +2 -2
- data/sig/manual/riffer/rig/terminal/count.rbs +6 -0
- data/sig/manual/riffer/rig/terminal/spinner.rbs +6 -0
- data/sig/manual/riffer/rig/terminal/status_bar.rbs +6 -0
- metadata +21 -8
- data/lib/riffer/rig/settings/pricing.rb +0 -40
- data/lib/riffer/rig/terminal/equalizer.rb +0 -20
- data/sig/generated/riffer/rig/settings/pricing.rbs +0 -31
- 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
|
|
30
|
-
# @rbs
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
50
|
-
# @rbs provider: String?
|
|
37
|
+
# @rbs model: String
|
|
51
38
|
# @rbs return: Hash[Symbol, untyped]
|
|
52
|
-
def
|
|
53
|
-
return {}
|
|
39
|
+
def model_options(model)
|
|
40
|
+
return { cache_control: { type: :ephemeral } } if provider_for(model) == 'anthropic'
|
|
54
41
|
|
|
55
|
-
|
|
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
|
data/lib/riffer/rig/settings.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
19
|
-
# first message (a /model before the first prompt) wait
|
|
20
|
-
# resumed session appends to the file its first run wrote,
|
|
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
|
-
|
|
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 { [
|
|
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
|
-
|
|
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 >=
|
|
223
|
-
|
|
224
|
-
write("#{before}#{
|
|
225
|
-
@rows_above =
|
|
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
|
-
#
|
|
267
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
)
|
|
277
|
-
|
|
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(
|
|
386
|
+
typing - Riffer::Rig::Terminal::Columns.width(line)
|
|
281
387
|
)
|
|
282
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|