llm.rb 12.1.0 → 12.3.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 +248 -0
- data/README.md +77 -27
- data/data/anthropic.json +3 -2
- data/data/bedrock.json +47 -0
- data/data/deepinfra.json +2 -2
- data/data/google.json +32 -0
- data/data/mistral.json +968 -0
- data/data/openai.json +422 -0
- data/data/xai.json +60 -1
- data/lib/llm/active_record/acts_as_agent.rb +11 -0
- data/lib/llm/agent.rb +27 -3
- data/lib/llm/compactor.rb +2 -2
- data/lib/llm/context.rb +18 -26
- data/lib/llm/function.rb +6 -0
- data/lib/llm/object.rb +13 -0
- data/lib/llm/provider.rb +12 -9
- data/lib/llm/providers/anthropic.rb +4 -5
- data/lib/llm/providers/bedrock.rb +4 -5
- data/lib/llm/providers/google.rb +7 -3
- data/lib/llm/providers/mistral/request_adapter/completion.rb +122 -0
- data/lib/llm/providers/mistral/request_adapter.rb +20 -0
- data/lib/llm/providers/mistral.rb +145 -0
- data/lib/llm/providers/ollama/response_adapter/completion.rb +39 -0
- data/lib/llm/providers/ollama.rb +2 -3
- data/lib/llm/providers/openai/responses.rb +6 -3
- data/lib/llm/providers/openai.rb +2 -3
- data/lib/llm/repl/bar.rb +52 -0
- data/lib/llm/repl/input.rb +143 -30
- data/lib/llm/repl/markdown.rb +85 -0
- data/lib/llm/repl/status.rb +16 -5
- data/lib/llm/repl/stream.rb +15 -5
- data/lib/llm/repl/transcript.rb +107 -18
- data/lib/llm/repl/window.rb +43 -17
- data/lib/llm/repl.rb +99 -18
- data/lib/llm/sequel/agent.rb +11 -0
- data/lib/llm/skill.rb +1 -1
- data/lib/llm/stream/disabled.rb +23 -0
- data/lib/llm/stream/io.rb +43 -0
- data/lib/llm/stream.rb +34 -0
- data/lib/llm/tools/git.rb +2 -3
- data/lib/llm/tools/pwd.rb +0 -1
- data/lib/llm/tools/rg.rb +2 -1
- data/lib/llm/tools/swap_text.rb +6 -0
- data/lib/llm/transport/execution.rb +1 -0
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +19 -0
- data/llm.gemspec +8 -4
- data/resources/deepdive.md +170 -50
- metadata +15 -4
data/lib/llm/repl/bar.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Repl
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Repl::Bar LLM::Repl::Bar} class renders a
|
|
6
|
+
# small progress bar for the REPL. It is used to show
|
|
7
|
+
# the remaining size of the model's context window in
|
|
8
|
+
# a compact form near the input line.
|
|
9
|
+
# @api private
|
|
10
|
+
class Bar
|
|
11
|
+
##
|
|
12
|
+
# @return [String]
|
|
13
|
+
OCCUPIED = "█"
|
|
14
|
+
|
|
15
|
+
##
|
|
16
|
+
# @return [String]
|
|
17
|
+
FREE = " "
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# @param [Integer] used
|
|
21
|
+
# @param [Integer] total
|
|
22
|
+
# @param [Integer] width
|
|
23
|
+
# @return [LLM::Repl::Bar]
|
|
24
|
+
def initialize(used:, total:, width: 10)
|
|
25
|
+
@width = width
|
|
26
|
+
@label, @filled = remainder(used, total)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# @return [String]
|
|
31
|
+
def to_s
|
|
32
|
+
bar = "#{OCCUPIED * filled}#{FREE * (width - filled)}"
|
|
33
|
+
"│#{bar}│ #{label}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @param [Integer] used
|
|
40
|
+
# @param [Integer] total
|
|
41
|
+
# @return [[String, Integer]]
|
|
42
|
+
def remainder(used, total)
|
|
43
|
+
return ["???", width] if total <= 0
|
|
44
|
+
diff = total - used
|
|
45
|
+
return ["0%", 0] if diff <= 0
|
|
46
|
+
remaining = (diff.fdiv(total) * 100).round(2)
|
|
47
|
+
["#{remaining}%", ((remaining / 100) * width).round]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :label, :filled, :width
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/llm/repl/input.rb
CHANGED
|
@@ -6,59 +6,172 @@ class LLM::Repl
|
|
|
6
6
|
# the editable input line shown at the bottom of the REPL.
|
|
7
7
|
# @api private
|
|
8
8
|
class Input
|
|
9
|
+
CTRL_A = 1
|
|
10
|
+
CTRL_E = 5
|
|
9
11
|
UP = Curses::Key::UP
|
|
10
12
|
DOWN = Curses::Key::DOWN
|
|
13
|
+
LEFT = Curses::Key::LEFT
|
|
14
|
+
RIGHT = Curses::Key::RIGHT
|
|
11
15
|
ENTER = [Curses::Key::ENTER, 10, 13]
|
|
12
16
|
BACKSPACE = [Curses::Key::BACKSPACE, 127]
|
|
13
|
-
EOF = [
|
|
17
|
+
EOF = [4]
|
|
14
18
|
|
|
15
19
|
##
|
|
16
|
-
# @param [
|
|
20
|
+
# @param [LLM::Agent] agent
|
|
17
21
|
# @return [LLM::Repl::Input]
|
|
18
|
-
def initialize(
|
|
19
|
-
@
|
|
22
|
+
def initialize(agent, options = {})
|
|
23
|
+
@agent = agent
|
|
24
|
+
@provider = agent.llm.name
|
|
20
25
|
@buffer = +""
|
|
26
|
+
@cursor = 0
|
|
27
|
+
@scroll = 0
|
|
28
|
+
@height = options.fetch(:height, 3)
|
|
21
29
|
end
|
|
22
30
|
|
|
23
31
|
##
|
|
24
32
|
# @param [LLM::Repl::Window] window
|
|
25
|
-
# @
|
|
26
|
-
|
|
27
|
-
catch(:done) do
|
|
28
|
-
@buffer.clear
|
|
29
|
-
loop do
|
|
30
|
-
on_char(window, window.getch)
|
|
31
|
-
window.redraw
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
##
|
|
37
|
-
# @return [String]
|
|
38
|
-
def to_s
|
|
39
|
-
"> #{@buffer}"
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
33
|
+
# @param [Object] char
|
|
34
|
+
# @return [Symbol, nil]
|
|
44
35
|
def on_char(window, char)
|
|
45
36
|
if EOF.include?(char)
|
|
46
|
-
|
|
37
|
+
:exit
|
|
47
38
|
elsif BACKSPACE.include?(char)
|
|
48
|
-
|
|
39
|
+
backspace
|
|
40
|
+
:backspace
|
|
49
41
|
elsif ENTER.include?(char)
|
|
50
|
-
|
|
51
|
-
@buffer.clear
|
|
52
|
-
throw(:done, buf)
|
|
42
|
+
:submit
|
|
53
43
|
elsif char == UP
|
|
54
44
|
window.scroll_up
|
|
45
|
+
:up
|
|
55
46
|
elsif char == DOWN
|
|
56
47
|
window.scroll_down
|
|
48
|
+
:down
|
|
49
|
+
elsif char == CTRL_A
|
|
50
|
+
move_start
|
|
51
|
+
:ctrl_a
|
|
52
|
+
elsif char == CTRL_E
|
|
53
|
+
move_end
|
|
54
|
+
:ctrl_e
|
|
55
|
+
elsif char == LEFT
|
|
56
|
+
move_left
|
|
57
|
+
:left
|
|
58
|
+
elsif char == RIGHT
|
|
59
|
+
move_right
|
|
60
|
+
:right
|
|
57
61
|
elsif String === char
|
|
58
|
-
|
|
62
|
+
insert(char)
|
|
63
|
+
:char
|
|
59
64
|
else
|
|
60
|
-
|
|
65
|
+
nil
|
|
61
66
|
end
|
|
62
67
|
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# @return [String]
|
|
71
|
+
def to_s
|
|
72
|
+
"#{@provider}> #{@buffer}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
##
|
|
76
|
+
# @return [Integer]
|
|
77
|
+
def cursor
|
|
78
|
+
prompt.length + @cursor
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# @return [Integer]
|
|
83
|
+
def height
|
|
84
|
+
@height
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
##
|
|
88
|
+
# Returns the visible lines of the input buffer,
|
|
89
|
+
# wrapped at the given column width. The viewport
|
|
90
|
+
# follows the cursor so the cursor line is always
|
|
91
|
+
# visible.
|
|
92
|
+
# @param [Integer] cols
|
|
93
|
+
# @return [Array<String>]
|
|
94
|
+
def lines(cols)
|
|
95
|
+
sync_scroll(cols)
|
|
96
|
+
text = to_s
|
|
97
|
+
chunks = text.chars.each_slice(cols).map(&:join)
|
|
98
|
+
chunks = [""] if chunks.empty?
|
|
99
|
+
chunks[@scroll, height] || []
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
##
|
|
103
|
+
# Returns the cursor position as [line, column] within
|
|
104
|
+
# the visible viewport.
|
|
105
|
+
# @param [Integer] cols
|
|
106
|
+
# @return [Array(Integer, Integer)]
|
|
107
|
+
def cursor_pos(cols)
|
|
108
|
+
sync_scroll(cols)
|
|
109
|
+
[(cursor / cols) - @scroll, cursor % cols]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
##
|
|
113
|
+
# @return [void]
|
|
114
|
+
def move_start
|
|
115
|
+
@cursor = 0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
##
|
|
119
|
+
# @return [void]
|
|
120
|
+
def move_end
|
|
121
|
+
@cursor = [0, @buffer.size].max
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
##
|
|
125
|
+
# @return [void]
|
|
126
|
+
def move_left
|
|
127
|
+
@cursor = [@cursor - 1, 0].max
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
##
|
|
131
|
+
# @return [void]
|
|
132
|
+
def move_right
|
|
133
|
+
@cursor = [@cursor + 1, @buffer.size].min
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
##
|
|
137
|
+
# @return [String]
|
|
138
|
+
def take
|
|
139
|
+
@buffer.dup.tap do
|
|
140
|
+
@buffer.clear
|
|
141
|
+
@cursor = 0
|
|
142
|
+
@scroll = 0
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
##
|
|
149
|
+
# Adjusts @scroll so the cursor line is visible within
|
|
150
|
+
# the viewport.
|
|
151
|
+
def sync_scroll(cols)
|
|
152
|
+
total_lines = [1, (to_s.length.to_f / cols).ceil].max
|
|
153
|
+
cursor_line = cursor / cols
|
|
154
|
+
if cursor_line < @scroll
|
|
155
|
+
@scroll = cursor_line
|
|
156
|
+
elsif cursor_line >= (@scroll + height)
|
|
157
|
+
@scroll = (cursor_line - height) + 1
|
|
158
|
+
end
|
|
159
|
+
@scroll = [[@scroll, (total_lines - height)].min, 0].max
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def prompt
|
|
163
|
+
"#{@provider}> "
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def insert(char)
|
|
167
|
+
@buffer.insert(@cursor, char)
|
|
168
|
+
@cursor += char.length
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def backspace
|
|
172
|
+
return if @cursor <= 0
|
|
173
|
+
@buffer.slice!(@cursor - 1)
|
|
174
|
+
@cursor -= 1
|
|
175
|
+
end
|
|
63
176
|
end
|
|
64
177
|
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Repl
|
|
4
|
+
##
|
|
5
|
+
# This class is designed to represent a markdown
|
|
6
|
+
# string (typically from a model's response) as a
|
|
7
|
+
# tree of objects where each object contains a piece
|
|
8
|
+
# of text, and also optional style information for
|
|
9
|
+
# that text (eg bold, underscore, ...)
|
|
10
|
+
class Markdown
|
|
11
|
+
##
|
|
12
|
+
# @param [String] text
|
|
13
|
+
# @return [LLM::Repl::Markdown]
|
|
14
|
+
def initialize(text)
|
|
15
|
+
@doc = Kramdown::Document.new(text)
|
|
16
|
+
@ast = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# @return [Array<Hash>]
|
|
21
|
+
def ast
|
|
22
|
+
@ast.tap do
|
|
23
|
+
##
|
|
24
|
+
# Recurisvely travels the markdown document and
|
|
25
|
+
# populates the `@ast` variable along the way.
|
|
26
|
+
# The AST is composed of structured data that
|
|
27
|
+
# carries both text and styling information that
|
|
28
|
+
# is applied by the UI thread.
|
|
29
|
+
walk(@doc.root)
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# This is required because the AST collects
|
|
33
|
+
# empty nodes towards the end of the tree.
|
|
34
|
+
# If we don't pop them we end up with excessive
|
|
35
|
+
# amount of newlines between turns.
|
|
36
|
+
last = @ast.last
|
|
37
|
+
while last and last[:text].to_s.strip.empty?
|
|
38
|
+
@ast.pop
|
|
39
|
+
last = @ast.last
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
##
|
|
47
|
+
# Recursively walk from the head node to the
|
|
48
|
+
# tail node. This method mutates the `@ast`
|
|
49
|
+
# variable. A future refactor might be worthwhile
|
|
50
|
+
# since this method is implemented with side effects,
|
|
51
|
+
# but it probably could return the ast instead.
|
|
52
|
+
def walk(node, attrs = nil)
|
|
53
|
+
case node.type
|
|
54
|
+
when :root
|
|
55
|
+
node.children.each { walk(_1, attrs) }
|
|
56
|
+
when :text
|
|
57
|
+
emit(node.value.to_s, attrs)
|
|
58
|
+
when :p
|
|
59
|
+
node.children.each { walk(_1, attrs) }
|
|
60
|
+
emit("\n\n", attrs)
|
|
61
|
+
when :header
|
|
62
|
+
emit("\n", attrs)
|
|
63
|
+
node.children.each { walk(_1, Curses::A_BOLD) }
|
|
64
|
+
emit("\n", attrs)
|
|
65
|
+
when :strong
|
|
66
|
+
node.children.each { walk(_1, Curses::A_BOLD) }
|
|
67
|
+
when :em
|
|
68
|
+
node.children.each { walk(_1, Curses::A_UNDERLINE) }
|
|
69
|
+
when :codespan
|
|
70
|
+
emit(node.value, Curses::A_REVERSE)
|
|
71
|
+
when :codeblock
|
|
72
|
+
emit(node.value, Curses::A_REVERSE)
|
|
73
|
+
emit("\n\n", attrs)
|
|
74
|
+
when :br
|
|
75
|
+
emit("\n", attrs)
|
|
76
|
+
else
|
|
77
|
+
node.children.each { walk(_1, attrs) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def emit(text, attrs)
|
|
82
|
+
@ast.push({text: text.to_s, attrs:}.compact)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/llm/repl/status.rb
CHANGED
|
@@ -9,18 +9,29 @@ class LLM::Repl
|
|
|
9
9
|
##
|
|
10
10
|
# @param [String, Symbol] provider
|
|
11
11
|
# @return [LLM::Repl::Status]
|
|
12
|
-
def initialize(
|
|
13
|
-
@
|
|
12
|
+
def initialize(agent)
|
|
13
|
+
@agent = agent
|
|
14
|
+
@provider = agent.llm.name
|
|
14
15
|
@text = "idle"
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
##
|
|
18
19
|
# @return [String]
|
|
19
|
-
|
|
20
|
+
def context_bar
|
|
21
|
+
LLM::Repl::Bar.new(
|
|
22
|
+
used: @agent.usage.total_tokens,
|
|
23
|
+
total: @agent.context_window
|
|
24
|
+
).to_s
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
##
|
|
28
|
+
# @return [String]
|
|
29
|
+
def cost
|
|
30
|
+
"$#{@agent.cost}"
|
|
31
|
+
end
|
|
20
32
|
|
|
21
33
|
##
|
|
22
|
-
# @
|
|
23
|
-
# @return [void]
|
|
34
|
+
# @return [String]
|
|
24
35
|
attr_accessor :text
|
|
25
36
|
|
|
26
37
|
##
|
data/lib/llm/repl/stream.rb
CHANGED
|
@@ -11,8 +11,10 @@ class LLM::Repl
|
|
|
11
11
|
##
|
|
12
12
|
# @param [LLM::Repl] repl
|
|
13
13
|
# @return [LLM::Repl::Stream]
|
|
14
|
-
def initialize(repl)
|
|
14
|
+
def initialize(repl, queue)
|
|
15
15
|
@repl = repl
|
|
16
|
+
@_queue = queue
|
|
17
|
+
@buffer = +""
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
##
|
|
@@ -20,7 +22,8 @@ class LLM::Repl
|
|
|
20
22
|
# One or more chars
|
|
21
23
|
# @return [void]
|
|
22
24
|
def on_content(chars)
|
|
23
|
-
@
|
|
25
|
+
@buffer << chars
|
|
26
|
+
@_queue.push [:stream, @buffer]
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
##
|
|
@@ -29,9 +32,9 @@ class LLM::Repl
|
|
|
29
32
|
# @return [void]
|
|
30
33
|
def on_tool_call(tool, error)
|
|
31
34
|
if error
|
|
32
|
-
@
|
|
35
|
+
@_queue.push [:status, "tool not found: #{tool.name}"]
|
|
33
36
|
else
|
|
34
|
-
@
|
|
37
|
+
@_queue.push [:status, "tool: #{tool.name}"]
|
|
35
38
|
end
|
|
36
39
|
end
|
|
37
40
|
|
|
@@ -40,7 +43,14 @@ class LLM::Repl
|
|
|
40
43
|
# @param [LLM::Function::Return] result
|
|
41
44
|
# @return [void]
|
|
42
45
|
def on_tool_return(_tool, result)
|
|
43
|
-
@
|
|
46
|
+
@_queue.push [:status, "tool done: #{result.name}"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# Empty the accumulated buffer
|
|
51
|
+
# @return [void]
|
|
52
|
+
def empty!
|
|
53
|
+
@buffer.clear
|
|
44
54
|
end
|
|
45
55
|
end
|
|
46
56
|
end
|
data/lib/llm/repl/transcript.rb
CHANGED
|
@@ -2,30 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
class LLM::Repl
|
|
4
4
|
##
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# This class maintains conversation state that includes
|
|
6
|
+
# the conversation itself, and metadata associated with
|
|
7
|
+
# the conversation.
|
|
8
|
+
#
|
|
9
|
+
# Internally it maintains an array where each element
|
|
10
|
+
# represents a row, and each element in a row is a Hash
|
|
11
|
+
# that describes a piece of text and any styles that might
|
|
12
|
+
# be applied to it by the UI thread.
|
|
13
|
+
#
|
|
14
|
+
# It also maintains a cursor that tracks the active row
|
|
15
|
+
# by its index number. The streaming path reuses a single
|
|
16
|
+
# row by overwriting its contents repeatedly.
|
|
8
17
|
class Transcript
|
|
9
18
|
WIDTH = 80
|
|
10
19
|
|
|
11
20
|
##
|
|
12
21
|
# @return [LLM::Repl::Transcript]
|
|
13
22
|
def initialize
|
|
14
|
-
@
|
|
23
|
+
@rows = [[]]
|
|
24
|
+
@cursor = nil
|
|
25
|
+
@snapshot = nil
|
|
15
26
|
@offset = 0
|
|
16
27
|
end
|
|
17
28
|
|
|
18
29
|
##
|
|
19
30
|
# @param [String] chars
|
|
31
|
+
# @param [Object] attrs
|
|
32
|
+
# @param [Symbol] method
|
|
20
33
|
# @return [void]
|
|
21
|
-
def write(chars)
|
|
22
|
-
|
|
34
|
+
def write(chars, attrs = nil, method: :append)
|
|
35
|
+
chunks = [{text: chars.to_s, attrs:}.compact]
|
|
36
|
+
self.method(method).call(chunks)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# Appends Markdown to the transcript.
|
|
41
|
+
# @param [String] chars
|
|
42
|
+
# @param [Symbol] method
|
|
43
|
+
# @return [void]
|
|
44
|
+
def markdown(chars, method: :append)
|
|
45
|
+
chunks = LLM::Repl::Markdown.new(chars).ast
|
|
46
|
+
self.method(method).call(chunks)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
##
|
|
50
|
+
# Start the transcript.
|
|
51
|
+
# @return [void]
|
|
52
|
+
def start
|
|
53
|
+
@cursor = @rows.size - 1
|
|
54
|
+
@snapshot = @rows.map(&:dup)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
##
|
|
58
|
+
# Finish the transcript.
|
|
59
|
+
# @return [void]
|
|
60
|
+
def finish
|
|
61
|
+
@cursor = nil
|
|
62
|
+
@snapshot = nil
|
|
23
63
|
end
|
|
24
64
|
|
|
25
65
|
##
|
|
26
66
|
# @return [void]
|
|
27
67
|
def scroll_up(height)
|
|
28
|
-
max = [
|
|
68
|
+
max = [rows.size - height, 0].max
|
|
29
69
|
@offset = [@offset + 1, max].min
|
|
30
70
|
end
|
|
31
71
|
|
|
@@ -39,23 +79,72 @@ class LLM::Repl
|
|
|
39
79
|
# @param [Integer] height
|
|
40
80
|
# @return [Array<String>]
|
|
41
81
|
def visible(height)
|
|
42
|
-
|
|
82
|
+
all = rows
|
|
83
|
+
last = all.size - 1 - @offset
|
|
43
84
|
first = [last - height + 1, 0].max
|
|
44
|
-
|
|
85
|
+
all[first..last] || []
|
|
45
86
|
end
|
|
46
87
|
|
|
47
88
|
private
|
|
48
89
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
90
|
+
##
|
|
91
|
+
# Appends a new row
|
|
92
|
+
# @param [Array<{text: String, attrs?: Integer}>] chunks
|
|
93
|
+
# One or more chunks.
|
|
94
|
+
# @return [void]
|
|
95
|
+
def append(chunks)
|
|
96
|
+
chunks.each { wrap(_1, @rows) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
##
|
|
100
|
+
# Replaces the content of the active row
|
|
101
|
+
# @param [Array<{text: String, attrs?: Integer}>] chunks
|
|
102
|
+
# One or more chunks.
|
|
103
|
+
# @return [void]
|
|
104
|
+
def replace(chunks)
|
|
105
|
+
@rows = @snapshot.map(&:dup)
|
|
106
|
+
append(chunks)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
##
|
|
110
|
+
# Given a chunk this method wraps text at
|
|
111
|
+
# around 80 columns: a new row starts when
|
|
112
|
+
# the current character is " " and the sum
|
|
113
|
+
# of all characters in that row is greater
|
|
114
|
+
# than 80 columns.
|
|
115
|
+
def wrap(chunk, rows)
|
|
116
|
+
attrs = chunk[:attrs]
|
|
117
|
+
chunk[:text].to_s.each_char do |char|
|
|
118
|
+
if char == "\n"
|
|
119
|
+
rows << []
|
|
120
|
+
elsif char == " " and sum(rows.last) >= WIDTH
|
|
121
|
+
rows << []
|
|
122
|
+
else
|
|
123
|
+
rows.last << {text: char, attrs:}.compact
|
|
124
|
+
end
|
|
58
125
|
end
|
|
59
126
|
end
|
|
127
|
+
|
|
128
|
+
##
|
|
129
|
+
# @api private
|
|
130
|
+
def rows
|
|
131
|
+
@rows.dup.tap do |rows|
|
|
132
|
+
##
|
|
133
|
+
# Discard empty rows that would otherwise
|
|
134
|
+
# be rendered as newlines by the UI thread.
|
|
135
|
+
# It's not the most elegant way to deal with
|
|
136
|
+
# this and we probably shouldn't allow it to
|
|
137
|
+
# happen in the first place.
|
|
138
|
+
while rows.size > 1 and rows.last.empty?
|
|
139
|
+
rows.pop
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
##
|
|
145
|
+
# @api private
|
|
146
|
+
def sum(row)
|
|
147
|
+
row.sum { _1[:text].to_s.length }
|
|
148
|
+
end
|
|
60
149
|
end
|
|
61
150
|
end
|
data/lib/llm/repl/window.rb
CHANGED
|
@@ -37,6 +37,7 @@ class LLM::Repl
|
|
|
37
37
|
Curses.cbreak
|
|
38
38
|
Curses.noecho
|
|
39
39
|
Curses.stdscr.keypad(true)
|
|
40
|
+
Curses.stdscr.nodelay = true
|
|
40
41
|
yield
|
|
41
42
|
ensure
|
|
42
43
|
Curses.close_screen
|
|
@@ -46,8 +47,9 @@ class LLM::Repl
|
|
|
46
47
|
# @return [void]
|
|
47
48
|
def redraw
|
|
48
49
|
Curses.clear
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
draw_divider(offset: 5)
|
|
51
|
+
draw_status(offset: input.height + 1)
|
|
52
|
+
draw_transcript(offset: 0)
|
|
51
53
|
draw_input
|
|
52
54
|
Curses.refresh
|
|
53
55
|
end
|
|
@@ -55,7 +57,7 @@ class LLM::Repl
|
|
|
55
57
|
##
|
|
56
58
|
# @return [Integer]
|
|
57
59
|
def rows
|
|
58
|
-
[Curses.lines -
|
|
60
|
+
[Curses.lines - (input.height + 4), 1].max
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
##
|
|
@@ -78,28 +80,52 @@ class LLM::Repl
|
|
|
78
80
|
|
|
79
81
|
private
|
|
80
82
|
|
|
81
|
-
def draw_status
|
|
82
|
-
Curses.setpos(
|
|
83
|
+
def draw_status(offset:)
|
|
84
|
+
Curses.setpos(Curses.lines - offset, 0)
|
|
85
|
+
Curses.clrtoeol
|
|
83
86
|
Curses.addstr(status.to_s)
|
|
84
|
-
|
|
85
|
-
Curses.setpos(
|
|
86
|
-
Curses.addstr(
|
|
87
|
+
context = status.context_bar
|
|
88
|
+
Curses.setpos(Curses.lines - offset, [(columns - context.length) / 2, 0].max)
|
|
89
|
+
Curses.addstr(context)
|
|
90
|
+
cost = status.cost.to_s
|
|
91
|
+
Curses.setpos(Curses.lines - offset, [columns - cost.length, 0].max)
|
|
92
|
+
Curses.addstr(cost)
|
|
87
93
|
end
|
|
88
94
|
|
|
89
|
-
def
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
Curses.addstr(row)
|
|
94
|
-
end
|
|
95
|
+
def draw_divider(offset:)
|
|
96
|
+
Curses.setpos(Curses.lines - offset, 0)
|
|
97
|
+
Curses.clrtoeol
|
|
98
|
+
Curses.addstr("─" * Curses.cols)
|
|
95
99
|
end
|
|
96
100
|
|
|
97
101
|
def draw_input
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
cols = columns
|
|
103
|
+
rows = input.lines(cols)
|
|
104
|
+
rows.each.with_index do |line, idx|
|
|
105
|
+
Curses.setpos((Curses.lines - input.height) + idx, 0)
|
|
106
|
+
Curses.clrtoeol
|
|
107
|
+
Curses.addstr(line)
|
|
108
|
+
end
|
|
109
|
+
line, col = input.cursor_pos(cols)
|
|
110
|
+
Curses.setpos((Curses.lines - input.height) + line, col)
|
|
101
111
|
end
|
|
102
112
|
|
|
113
|
+
def draw_transcript(offset:)
|
|
114
|
+
rows = transcript.visible(self.rows)
|
|
115
|
+
rows.each.with_index(offset) do |row, index|
|
|
116
|
+
Curses.setpos(index, 0)
|
|
117
|
+
Curses.clrtoeol
|
|
118
|
+
row.each do |chunk|
|
|
119
|
+
text, attrs = chunk.values_at(:text, :attrs)
|
|
120
|
+
Curses.attron(attrs) if attrs
|
|
121
|
+
Curses.addstr(text)
|
|
122
|
+
Curses.attroff(attrs) if attrs
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
##
|
|
128
|
+
# @return [Integer]
|
|
103
129
|
def columns
|
|
104
130
|
Curses.cols
|
|
105
131
|
end
|