llm.rb 13.1.0 → 15.0.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 +716 -1826
- data/README.md +668 -253
- data/bin/llm.rb +156 -56
- data/data/alibaba.json +1999 -0
- data/data/anthropic.json +195 -252
- data/data/bedrock.json +2189 -1854
- data/data/deepinfra.json +1312 -728
- data/data/deepseek.json +42 -39
- data/data/google.json +1024 -404
- data/data/mistral.json +481 -401
- data/data/moonshot.json +384 -0
- data/data/openai.json +985 -1354
- data/data/xai.json +220 -115
- data/data/zai.json +166 -166
- data/docs/deepdive/advanced/cancellation.md +74 -0
- data/docs/deepdive/advanced/compaction.md +85 -0
- data/docs/deepdive/advanced/context.md +280 -0
- data/docs/deepdive/advanced/guard.md +371 -0
- data/docs/deepdive/advanced/transformer.md +67 -0
- data/docs/deepdive/advanced/transports.md +45 -0
- data/docs/deepdive/features/builtin_tools.md +191 -0
- data/docs/deepdive/features/concurrency.md +110 -0
- data/docs/deepdive/features/database.md +449 -0
- data/docs/deepdive/features/embeddings.md +157 -0
- data/docs/deepdive/features/repl.md +141 -0
- data/docs/deepdive/fundamentals/agents.md +253 -0
- data/docs/deepdive/fundamentals/providers.md +159 -0
- data/docs/deepdive/fundamentals/schema.md +61 -0
- data/docs/deepdive/fundamentals/skills.md +111 -0
- data/docs/deepdive/fundamentals/stream.md +143 -0
- data/docs/deepdive/fundamentals/tools.md +329 -0
- data/docs/deepdive/media/audio.md +122 -0
- data/docs/deepdive/media/images.md +89 -0
- data/docs/deepdive/media/ocr.md +48 -0
- data/docs/deepdive/protocols/a2a.md +106 -0
- data/docs/deepdive/protocols/mcp.md +111 -0
- data/docs/deepdive/reference/cost.md +109 -0
- data/docs/deepdive/reference/model_registry.md +271 -0
- data/docs/deepdive/reference/object.md +108 -0
- data/docs/deepdive/reference/tracer.md +187 -0
- data/{resources → docs}/deepdive.md +37 -23
- data/lib/llm/a2a/transport/http.rb +1 -1
- data/lib/llm/active_record/acts_as_llm.rb +19 -5
- data/lib/llm/agent.rb +107 -17
- data/lib/llm/context.rb +164 -134
- data/lib/llm/cost.rb +114 -49
- data/lib/llm/error.rb +7 -8
- data/lib/llm/function/array.rb +1 -1
- data/lib/llm/function/async/task.rb +2 -0
- data/lib/llm/function/fiber/task.rb +2 -0
- data/lib/llm/function/fork/task.rb +16 -1
- data/lib/llm/function/ractor/task.rb +2 -0
- data/lib/llm/function/sequential/group.rb +20 -10
- data/lib/llm/function/sequential/task.rb +2 -9
- data/lib/llm/function/task.rb +4 -0
- data/lib/llm/function/thread/task.rb +2 -0
- data/lib/llm/function.rb +33 -4
- data/lib/llm/guard/loop.rb +89 -0
- data/lib/llm/guard/null.rb +19 -0
- data/lib/llm/guard.rb +61 -0
- data/lib/llm/message.rb +5 -4
- data/lib/llm/provider.rb +43 -0
- data/lib/llm/providers/alibaba/error_handler.rb +34 -0
- data/lib/llm/providers/alibaba/request_adapter.rb +13 -0
- data/lib/llm/providers/alibaba.rb +93 -0
- data/lib/llm/providers/anthropic/stream_parser.rb +1 -0
- data/lib/llm/providers/anthropic.rb +1 -9
- data/lib/llm/providers/bedrock/stream_parser.rb +1 -0
- data/lib/llm/providers/bedrock.rb +9 -9
- data/lib/llm/providers/deepseek/request_adapter.rb +2 -33
- data/lib/llm/providers/google/stream_parser.rb +1 -0
- data/lib/llm/providers/google.rb +1 -9
- data/lib/llm/providers/moonshot.rb +76 -0
- data/lib/llm/providers/ollama.rb +1 -9
- data/lib/llm/providers/openai/responses/stream_parser.rb +1 -0
- data/lib/llm/providers/openai/responses.rb +6 -9
- data/lib/llm/providers/openai/schema.rb +37 -0
- data/lib/llm/providers/openai/stream_parser.rb +1 -0
- data/lib/llm/providers/openai.rb +4 -12
- data/lib/llm/registry/model.rb +186 -0
- data/lib/llm/registry.rb +45 -14
- data/lib/llm/repl/bar.rb +11 -12
- data/lib/llm/repl/buffer.rb +43 -16
- data/lib/llm/repl/color.rb +85 -0
- data/lib/llm/repl/command.rb +12 -0
- data/lib/llm/repl/commands/model.rb +39 -0
- data/lib/llm/repl/input/cache.rb +45 -0
- data/lib/llm/repl/input/char.rb +46 -0
- data/lib/llm/repl/input/row.rb +39 -0
- data/lib/llm/repl/input.rb +327 -79
- data/lib/llm/repl/markdown/table.rb +6 -2
- data/lib/llm/repl/markdown.rb +56 -6
- data/lib/llm/repl/node.rb +7 -0
- data/lib/llm/repl/status.rb +54 -5
- data/lib/llm/repl/stream.rb +16 -4
- data/lib/llm/repl/walker.rb +3 -2
- data/lib/llm/repl/window.rb +111 -11
- data/lib/llm/repl.rb +47 -21
- data/lib/llm/sequel/plugin.rb +19 -5
- data/lib/llm/skill.rb +21 -8
- data/lib/llm/stream.rb +35 -7
- data/lib/llm/tool.rb +27 -0
- data/lib/llm/tools/rg.rb +2 -1
- data/lib/llm/transformer/null.rb +21 -0
- data/lib/llm/transformer.rb +55 -0
- data/lib/llm/transport/curb.rb +23 -3
- data/lib/llm/usage.rb +155 -9
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +121 -29
- data/llm.gemspec +16 -10
- metadata +100 -13
- data/lib/llm/loop_guard.rb +0 -107
data/lib/llm/repl/input.rb
CHANGED
|
@@ -6,6 +6,10 @@ 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
|
+
require_relative "input/row"
|
|
10
|
+
require_relative "input/char"
|
|
11
|
+
require_relative "input/cache"
|
|
12
|
+
|
|
9
13
|
CTRL = {
|
|
10
14
|
A: Curses::KEY_CTRL_A,
|
|
11
15
|
E: Curses::KEY_CTRL_E,
|
|
@@ -25,12 +29,13 @@ class LLM::Repl
|
|
|
25
29
|
REPEATS = {}
|
|
26
30
|
REPEATS.default = 0
|
|
27
31
|
|
|
28
|
-
UP
|
|
29
|
-
DOWN
|
|
30
|
-
LEFT
|
|
31
|
-
RIGHT
|
|
32
|
-
PGUP
|
|
33
|
-
PGDOWN
|
|
32
|
+
UP = Curses::Key::UP
|
|
33
|
+
DOWN = Curses::Key::DOWN
|
|
34
|
+
LEFT = Curses::Key::LEFT
|
|
35
|
+
RIGHT = Curses::Key::RIGHT
|
|
36
|
+
PGUP = Curses::KEY_PPAGE
|
|
37
|
+
PGDOWN = Curses::KEY_NPAGE
|
|
38
|
+
KEY_RESIZE = Curses::KEY_RESIZE
|
|
34
39
|
|
|
35
40
|
TAB = 9
|
|
36
41
|
ESC = 27
|
|
@@ -44,10 +49,6 @@ class LLM::Repl
|
|
|
44
49
|
# distinguishes a paste from manual typing.
|
|
45
50
|
PASTE_THRESHOLD = 0.05
|
|
46
51
|
|
|
47
|
-
##
|
|
48
|
-
# @return [String]
|
|
49
|
-
attr_reader :buffer
|
|
50
|
-
|
|
51
52
|
##
|
|
52
53
|
# @param [Boolean] value
|
|
53
54
|
# @return [void]
|
|
@@ -57,11 +58,12 @@ class LLM::Repl
|
|
|
57
58
|
# @param [LLM::Repl] repl
|
|
58
59
|
# @return [LLM::Repl::Input]
|
|
59
60
|
def initialize(repl, options = {})
|
|
61
|
+
@repl = repl
|
|
60
62
|
@name = repl.name
|
|
61
63
|
@agent = repl.agent
|
|
62
64
|
@provider = @agent.llm.name
|
|
63
|
-
@
|
|
64
|
-
@cursor = 0
|
|
65
|
+
@rows = [Row.new]
|
|
66
|
+
@cursor = [0, 0]
|
|
65
67
|
@scroll = 0
|
|
66
68
|
@height = options.fetch(:height, 3)
|
|
67
69
|
@last_char_at = nil
|
|
@@ -70,6 +72,13 @@ class LLM::Repl
|
|
|
70
72
|
@paste = false
|
|
71
73
|
end
|
|
72
74
|
|
|
75
|
+
##
|
|
76
|
+
# @return [String]
|
|
77
|
+
# The input buffer as an ordinary string.
|
|
78
|
+
def buffer
|
|
79
|
+
text
|
|
80
|
+
end
|
|
81
|
+
|
|
73
82
|
##
|
|
74
83
|
# @param [LLM::Repl::Window] window
|
|
75
84
|
# @param [Object] char
|
|
@@ -91,12 +100,10 @@ class LLM::Repl
|
|
|
91
100
|
elsif ESC == char
|
|
92
101
|
@agent.cancel!
|
|
93
102
|
elsif CTRL[:P] == char
|
|
94
|
-
|
|
95
|
-
@cursor = @buffer.size
|
|
103
|
+
set(text: @walker.prev.dup)
|
|
96
104
|
:ctrl_p
|
|
97
105
|
elsif CTRL[:N] == char
|
|
98
|
-
|
|
99
|
-
@cursor = @buffer.size
|
|
106
|
+
set(text: @walker.next.dup)
|
|
100
107
|
:ctrl_n
|
|
101
108
|
elsif CTRL[:D] == char
|
|
102
109
|
delete
|
|
@@ -122,18 +129,21 @@ class LLM::Repl
|
|
|
122
129
|
elsif char == RIGHT
|
|
123
130
|
move_right
|
|
124
131
|
:right
|
|
125
|
-
elsif
|
|
132
|
+
elsif char == BACKSPACE
|
|
126
133
|
backspace
|
|
127
134
|
:backspace
|
|
128
|
-
elsif
|
|
135
|
+
elsif char == ENTER
|
|
129
136
|
if @paste = is_paste.()
|
|
130
137
|
insert("\n")
|
|
131
138
|
:char
|
|
132
139
|
else
|
|
133
|
-
@memory.push(
|
|
140
|
+
@memory.push(text)
|
|
134
141
|
@walker.cursor = @memory.size
|
|
135
142
|
:submit
|
|
136
143
|
end
|
|
144
|
+
elsif char == KEY_RESIZE
|
|
145
|
+
window.resize
|
|
146
|
+
:resize
|
|
137
147
|
elsif char == UP
|
|
138
148
|
window.scroll_up
|
|
139
149
|
:up
|
|
@@ -141,7 +151,16 @@ class LLM::Repl
|
|
|
141
151
|
window.scroll_down
|
|
142
152
|
:down
|
|
143
153
|
elsif String === char
|
|
144
|
-
|
|
154
|
+
##
|
|
155
|
+
# A paste arrives as a burst of fast characters. Flag it so the
|
|
156
|
+
# next loop iteration drains the rest through read_paste in a
|
|
157
|
+
# single shot, instead of inserting one character at a time and
|
|
158
|
+
# redrawing after each. A string that is already the product of
|
|
159
|
+
# read_paste (multi-character) is inserted as-is, one char at a
|
|
160
|
+
# time, so every newline creates a row and every Char stays a
|
|
161
|
+
# single character.
|
|
162
|
+
@paste = true if char.length == 1 && is_paste.()
|
|
163
|
+
char.each_char { |c| insert(c) }
|
|
145
164
|
:char
|
|
146
165
|
else
|
|
147
166
|
nil
|
|
@@ -157,13 +176,10 @@ class LLM::Repl
|
|
|
157
176
|
##
|
|
158
177
|
# @return [String]
|
|
159
178
|
def to_s
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
# @return [Integer]
|
|
165
|
-
def cursor
|
|
166
|
-
prompt.size + @cursor
|
|
179
|
+
@rows
|
|
180
|
+
.each_with_index
|
|
181
|
+
.map { |row, i| (i.zero? ? prompt : "") + row.to_s }
|
|
182
|
+
.join("\n")
|
|
167
183
|
end
|
|
168
184
|
|
|
169
185
|
##
|
|
@@ -173,14 +189,15 @@ class LLM::Repl
|
|
|
173
189
|
end
|
|
174
190
|
|
|
175
191
|
##
|
|
176
|
-
# Returns the visible lines of the input buffer
|
|
177
|
-
#
|
|
178
|
-
# so the cursor line is always visible.
|
|
192
|
+
# Returns the visible lines of the input buffer. The viewport
|
|
193
|
+
# follows the cursor so the cursor line is always visible.
|
|
179
194
|
# @return [Array<String>]
|
|
180
195
|
def lines
|
|
181
196
|
scroll!
|
|
182
|
-
|
|
183
|
-
|
|
197
|
+
visible = @rows[@scroll, height] || []
|
|
198
|
+
visible.each_with_index.map do |row, i|
|
|
199
|
+
(i.zero? && @scroll.zero? ? prompt : "") + row.to_s
|
|
200
|
+
end
|
|
184
201
|
end
|
|
185
202
|
|
|
186
203
|
##
|
|
@@ -189,93 +206,128 @@ class LLM::Repl
|
|
|
189
206
|
# @return [Array(Integer, Integer)]
|
|
190
207
|
def cursor_pos
|
|
191
208
|
scroll!
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
[line - @scroll, col]
|
|
209
|
+
row, col = @cursor
|
|
210
|
+
col += prompt.size if row.zero?
|
|
211
|
+
[row - @scroll, col]
|
|
196
212
|
end
|
|
197
213
|
|
|
198
214
|
##
|
|
199
215
|
# @return [void]
|
|
200
216
|
def move_start
|
|
201
|
-
@cursor = 0
|
|
217
|
+
@cursor = [0, 0]
|
|
202
218
|
end
|
|
203
219
|
|
|
204
220
|
##
|
|
205
221
|
# @return [void]
|
|
206
222
|
def move_end
|
|
207
|
-
@cursor = [
|
|
223
|
+
@cursor = [@rows.size - 1, @rows.last.chars.size]
|
|
208
224
|
end
|
|
209
225
|
|
|
210
226
|
##
|
|
211
227
|
# @return [void]
|
|
212
228
|
def move_left
|
|
213
|
-
|
|
229
|
+
row, col = @cursor
|
|
230
|
+
@cursor =
|
|
231
|
+
if col > 0 then [row, col - 1]
|
|
232
|
+
elsif row > 0 then [row - 1, @rows[row - 1].chars.size]
|
|
233
|
+
else [0, 0]
|
|
234
|
+
end
|
|
214
235
|
end
|
|
215
236
|
|
|
216
237
|
##
|
|
217
238
|
# @return [void]
|
|
218
239
|
def move_right
|
|
219
|
-
|
|
240
|
+
row, col = @cursor
|
|
241
|
+
@cursor =
|
|
242
|
+
if col < @rows[row].chars.size then [row, col + 1]
|
|
243
|
+
elsif row < @rows.size - 1 then [row + 1, 0]
|
|
244
|
+
else [row, col]
|
|
245
|
+
end
|
|
220
246
|
end
|
|
221
247
|
|
|
222
248
|
##
|
|
223
249
|
# @return [void]
|
|
224
250
|
def move_forward
|
|
225
|
-
|
|
251
|
+
move_right
|
|
226
252
|
end
|
|
227
253
|
|
|
228
254
|
##
|
|
229
255
|
# @return [void]
|
|
230
256
|
def autocomplete
|
|
231
|
-
return unless
|
|
257
|
+
return unless text[0] == "/"
|
|
232
258
|
##
|
|
233
|
-
#
|
|
234
|
-
# that
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
259
|
+
# There are two kinds of an autocomplete
|
|
260
|
+
# that can take place. The first kind
|
|
261
|
+
# autocompletes command names, and the
|
|
262
|
+
# second kind autocompletes command
|
|
263
|
+
# arguments.
|
|
264
|
+
#
|
|
265
|
+
# Command arguments are given to the
|
|
266
|
+
# `Command#complete(...)` method and
|
|
267
|
+
# returns an array of potential candidates.
|
|
268
|
+
if text.include?(" ")
|
|
269
|
+
complete(:argument)
|
|
243
270
|
else
|
|
244
|
-
|
|
271
|
+
complete(:command)
|
|
245
272
|
end
|
|
246
|
-
@buffer = "/#{candidate}"
|
|
247
|
-
@cursor = @buffer.size
|
|
248
273
|
end
|
|
249
274
|
|
|
250
275
|
##
|
|
251
276
|
# @return [void]
|
|
252
277
|
def kill
|
|
253
|
-
|
|
254
|
-
@
|
|
255
|
-
@
|
|
278
|
+
row, col = @cursor
|
|
279
|
+
tail = @rows[row].chars[col..].map(&:to_s).join
|
|
280
|
+
tail += @rows[(row + 1)..].map(&:to_s).join("\n")
|
|
281
|
+
@copy = tail
|
|
282
|
+
@rows[row].chars.slice!(col..)
|
|
283
|
+
@rows = @rows[0..row]
|
|
284
|
+
@cursor = [row, col]
|
|
256
285
|
end
|
|
257
286
|
|
|
258
287
|
##
|
|
288
|
+
# Deletes the character at the cursor, or consumes
|
|
289
|
+
# the break and pulls up the next row at the end of
|
|
290
|
+
# a row.
|
|
259
291
|
# @return [void]
|
|
260
292
|
def delete
|
|
261
|
-
|
|
262
|
-
|
|
293
|
+
row, col = @cursor
|
|
294
|
+
if col < @rows[row].chars.size
|
|
295
|
+
@rows[row].chars.delete_at(col)
|
|
296
|
+
elsif row < @rows.size - 1
|
|
297
|
+
nxt = @rows[row + 1]
|
|
298
|
+
##
|
|
299
|
+
# A wrapped row was split at a space; restore it so
|
|
300
|
+
# the merged words don't run together.
|
|
301
|
+
@rows[row].chars << Char.new(" ") if nxt.break_type == :space
|
|
302
|
+
@rows[row].chars.concat(nxt.chars)
|
|
303
|
+
@rows.delete_at(row + 1)
|
|
304
|
+
end
|
|
263
305
|
end
|
|
264
306
|
|
|
265
307
|
##
|
|
266
308
|
# @return [void]
|
|
267
309
|
def restore
|
|
268
310
|
return unless @copy
|
|
269
|
-
|
|
270
|
-
@
|
|
311
|
+
row, col = @cursor
|
|
312
|
+
@copy.each_char do |char|
|
|
313
|
+
if char == "\n"
|
|
314
|
+
@rows.insert(row + 1, Row.new(:newline))
|
|
315
|
+
row += 1
|
|
316
|
+
col = 0
|
|
317
|
+
else
|
|
318
|
+
@rows[row].chars.insert(col, Char.new(char))
|
|
319
|
+
col += 1
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
@cursor = [row, col]
|
|
271
323
|
end
|
|
272
324
|
|
|
273
325
|
##
|
|
274
326
|
# @return [String]
|
|
275
327
|
def take
|
|
276
|
-
|
|
277
|
-
@
|
|
278
|
-
@cursor = 0
|
|
328
|
+
text.tap do
|
|
329
|
+
@rows = [Row.new]
|
|
330
|
+
@cursor = [0, 0]
|
|
279
331
|
@scroll = 0
|
|
280
332
|
end
|
|
281
333
|
end
|
|
@@ -288,16 +340,74 @@ class LLM::Repl
|
|
|
288
340
|
|
|
289
341
|
private
|
|
290
342
|
|
|
343
|
+
##
|
|
344
|
+
# Autocompletes command names and their arguments.
|
|
345
|
+
# @param [Symbol] kind
|
|
346
|
+
# The kind of completion
|
|
347
|
+
# @return [void]
|
|
348
|
+
def complete(kind)
|
|
349
|
+
case kind
|
|
350
|
+
when :command
|
|
351
|
+
fragment = text[1..]
|
|
352
|
+
candidates =
|
|
353
|
+
if @cache&.include?(fragment)
|
|
354
|
+
@cache.candidates
|
|
355
|
+
else
|
|
356
|
+
LLM::Command.complete(text)
|
|
357
|
+
end
|
|
358
|
+
candidate = cycle(candidates, fragment)
|
|
359
|
+
set(text: "/#{candidate}")
|
|
360
|
+
when :argument
|
|
361
|
+
name, *args = text.split(" ", -1)
|
|
362
|
+
command = LLM::Command.find_by(name: name[1..])
|
|
363
|
+
return unless command
|
|
364
|
+
fragment, index = args.last, args.size - 1
|
|
365
|
+
param = command.parameters.values.find { _1.index == index }
|
|
366
|
+
return unless param
|
|
367
|
+
cmd = command.new(@repl)
|
|
368
|
+
candidates =
|
|
369
|
+
if @cache&.include?(fragment)
|
|
370
|
+
@cache.candidates
|
|
371
|
+
else
|
|
372
|
+
list = cmd.method(:complete).call(param.name => fragment)
|
|
373
|
+
return if list.empty?
|
|
374
|
+
list
|
|
375
|
+
end
|
|
376
|
+
candidate = cycle(candidates, fragment)
|
|
377
|
+
set(text: "#{name} #{args[0..-2].compact.join(" ")} #{candidate}".squeeze(" "))
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
##
|
|
382
|
+
# Returns the next candidate, cycling through the list
|
|
383
|
+
# when the current value is already one of the candidates.
|
|
384
|
+
# @param [Array<String>] candidates
|
|
385
|
+
# @param [String] current
|
|
386
|
+
# @return [String]
|
|
387
|
+
def cycle(candidates, current)
|
|
388
|
+
return "" if candidates.empty?
|
|
389
|
+
index =
|
|
390
|
+
if @cache and @cache.candidates == candidates
|
|
391
|
+
@cache.next_index
|
|
392
|
+
elsif (i = candidates.index(current))
|
|
393
|
+
(i + 1) % candidates.size
|
|
394
|
+
else
|
|
395
|
+
0
|
|
396
|
+
end
|
|
397
|
+
@cache = Cache.new(candidates, index)
|
|
398
|
+
@cache.value
|
|
399
|
+
end
|
|
400
|
+
|
|
291
401
|
##
|
|
292
402
|
# Adjusts @scroll so the cursor line is visible within
|
|
293
403
|
# the viewport.
|
|
294
404
|
def scroll!(total_lines = nil)
|
|
295
|
-
total_lines ||=
|
|
296
|
-
|
|
297
|
-
if
|
|
298
|
-
@scroll =
|
|
299
|
-
elsif
|
|
300
|
-
@scroll = (
|
|
405
|
+
total_lines ||= @rows.size
|
|
406
|
+
cursor_row = @cursor[0]
|
|
407
|
+
if cursor_row < @scroll
|
|
408
|
+
@scroll = cursor_row
|
|
409
|
+
elsif cursor_row >= (@scroll + height)
|
|
410
|
+
@scroll = (cursor_row - height) + 1
|
|
301
411
|
end
|
|
302
412
|
@scroll = [[@scroll, (total_lines - height)].min, 0].max
|
|
303
413
|
end
|
|
@@ -306,20 +416,158 @@ class LLM::Repl
|
|
|
306
416
|
"#{@provider}(#{@name})> "
|
|
307
417
|
end
|
|
308
418
|
|
|
419
|
+
##
|
|
420
|
+
# Returns the line the cursor is on, including the prompt.
|
|
421
|
+
# @return [String]
|
|
422
|
+
def current_line
|
|
423
|
+
row = @cursor[0]
|
|
424
|
+
(row.zero? ? prompt : "") + @rows[row].to_s
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
##
|
|
428
|
+
# Inserts a character at the cursor, wrapping the current row at
|
|
429
|
+
# the terminal width by starting a new row. A word that would be
|
|
430
|
+
# cut in half is moved whole onto the new row.
|
|
431
|
+
# @param [String] char
|
|
432
|
+
# @return [void]
|
|
309
433
|
def insert(char)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
@
|
|
434
|
+
row, col = @cursor
|
|
435
|
+
if char == "\n"
|
|
436
|
+
@rows.insert(row + 1, Row.new(:newline))
|
|
437
|
+
@cursor = [row + 1, 0]
|
|
438
|
+
elsif current_line.size >= Curses.cols
|
|
439
|
+
if char == " "
|
|
440
|
+
@rows.insert(row + 1, Row.new(:space))
|
|
441
|
+
@cursor = [row + 1, 0]
|
|
442
|
+
elsif (index = current_line.rindex(" "))
|
|
443
|
+
offset = row.zero? ? prompt.size : 0
|
|
444
|
+
split = index - offset
|
|
445
|
+
tail = @rows[row].chars.slice!((split + 1)..) || []
|
|
446
|
+
@rows[row].chars.delete_at(split)
|
|
447
|
+
@rows.insert(row + 1, Row.new(:space))
|
|
448
|
+
@rows[row + 1].chars.concat(tail)
|
|
449
|
+
@rows[row + 1].chars << Char.new(char)
|
|
450
|
+
@cursor = [row + 1, @rows[row + 1].chars.size]
|
|
451
|
+
else
|
|
452
|
+
@rows.insert(row + 1, Row.new(:space))
|
|
453
|
+
@rows[row + 1].chars << Char.new(char)
|
|
454
|
+
@cursor = [row + 1, 1]
|
|
455
|
+
end
|
|
456
|
+
else
|
|
457
|
+
@rows[row].chars.insert(col, Char.new(char))
|
|
458
|
+
@cursor = [row, col + char.length]
|
|
313
459
|
end
|
|
314
|
-
@buffer.insert(@cursor, char)
|
|
315
|
-
@cursor += char.length
|
|
316
460
|
scroll!
|
|
317
461
|
end
|
|
318
462
|
|
|
463
|
+
##
|
|
464
|
+
# @return [void]
|
|
319
465
|
def backspace
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
466
|
+
row, col = @cursor
|
|
467
|
+
if col > 0
|
|
468
|
+
@rows[row].chars.delete_at(col - 1)
|
|
469
|
+
@cursor = [row, col - 1]
|
|
470
|
+
elsif row > 0
|
|
471
|
+
prev = @rows[row - 1]
|
|
472
|
+
prev.chars.concat(@rows[row].chars)
|
|
473
|
+
@rows.delete_at(row)
|
|
474
|
+
@cursor = [row - 1, prev.chars.size]
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
##
|
|
479
|
+
# Flattens the rows into a single string. Each row joins the one
|
|
480
|
+
# before it using its own `break_type`: a row started by an
|
|
481
|
+
# auto-wrap joins with a space, a row started by a real newline
|
|
482
|
+
# joins with a newline.
|
|
483
|
+
# @return [String]
|
|
484
|
+
def text
|
|
485
|
+
out = +""
|
|
486
|
+
@rows.each_with_index do |row, i|
|
|
487
|
+
out << row.to_s
|
|
488
|
+
nxt = @rows[i + 1]
|
|
489
|
+
if nxt
|
|
490
|
+
out << (nxt.break_type == :newline ? "\n" : " ")
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
out
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
##
|
|
497
|
+
# Feeds one character into the row model: newlines
|
|
498
|
+
# start a row, overflowing rows wrap at a word.
|
|
499
|
+
# @param [String] char
|
|
500
|
+
# @return [void]
|
|
501
|
+
def feed(char)
|
|
502
|
+
if char == "\n"
|
|
503
|
+
##
|
|
504
|
+
# A real newline is a row boundary in the model,
|
|
505
|
+
# not a character, so it becomes a new row.
|
|
506
|
+
@rows << Row.new(:newline)
|
|
507
|
+
return
|
|
508
|
+
end
|
|
509
|
+
row = @rows.last
|
|
510
|
+
##
|
|
511
|
+
# Only the first row shares its columns with the prompt,
|
|
512
|
+
# so it has fewer columns left for text.
|
|
513
|
+
width = Curses.cols - (row.equal?(@rows.first) ? prompt.size : 0)
|
|
514
|
+
if row.chars.any? and row.to_s.length + 1 > width
|
|
515
|
+
wrap(row)
|
|
516
|
+
end
|
|
517
|
+
@rows.last.chars << Char.new(char)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
##
|
|
521
|
+
# Moves the word tail of a full row onto a new
|
|
522
|
+
# continuation row, keeping the overflow word whole.
|
|
523
|
+
# @param [LLM::Repl::Input::Row] row
|
|
524
|
+
# @return [void]
|
|
525
|
+
def wrap(row)
|
|
526
|
+
index = row.to_s.rindex(" ")
|
|
527
|
+
if index
|
|
528
|
+
##
|
|
529
|
+
# Move everything after the last space down and
|
|
530
|
+
# drop the space; break_type restores it on flatten.
|
|
531
|
+
tail = row.chars.slice!((index + 1)..) || []
|
|
532
|
+
row.chars.pop
|
|
533
|
+
@rows << Row.new(:space).tap { |r| r.chars.concat(tail) }
|
|
534
|
+
else
|
|
535
|
+
##
|
|
536
|
+
# A single unbroken word longer than the width hard-breaks.
|
|
537
|
+
@rows << Row.new(:space)
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
##
|
|
542
|
+
# Replaces the input with the given text, reflowing it into rows
|
|
543
|
+
# at the terminal width. The cursor ends at the last row.
|
|
544
|
+
# @param [String] text
|
|
545
|
+
# @return [void]
|
|
546
|
+
def set(text:)
|
|
547
|
+
return if noop_set?(text)
|
|
548
|
+
@rows = [Row.new]
|
|
549
|
+
text.each_char { |char| feed(char) }
|
|
550
|
+
##
|
|
551
|
+
# A trailing newline leaves an empty last row; drop it
|
|
552
|
+
# so the cursor rests at the end of the content row.
|
|
553
|
+
@rows.pop while @rows.size > 1 and @rows.last.empty?
|
|
554
|
+
@cursor = [@rows.size - 1, @rows.last.chars.size]
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
##
|
|
558
|
+
# Returns true when the {#set} method should return early.
|
|
559
|
+
# @param [String, nil] str
|
|
560
|
+
# The new string to set.
|
|
561
|
+
# @return [Boolean]
|
|
562
|
+
def noop_set?(str)
|
|
563
|
+
if str.nil?
|
|
564
|
+
true
|
|
565
|
+
elsif @rows.size == 1
|
|
566
|
+
chars = @rows[0].to_s
|
|
567
|
+
str.empty? and chars.empty?
|
|
568
|
+
else
|
|
569
|
+
false
|
|
570
|
+
end
|
|
323
571
|
end
|
|
324
572
|
end
|
|
325
573
|
end
|
|
@@ -6,8 +6,10 @@ class LLM::Repl::Markdown
|
|
|
6
6
|
module Table
|
|
7
7
|
##
|
|
8
8
|
# @api private
|
|
9
|
-
Node
|
|
9
|
+
Node = LLM::Repl::Node
|
|
10
|
+
Color = LLM::Repl::Color
|
|
10
11
|
private_constant :Node
|
|
12
|
+
private_constant :Color
|
|
11
13
|
|
|
12
14
|
##
|
|
13
15
|
# Renders a table node by collecting all cells first to
|
|
@@ -61,9 +63,11 @@ class LLM::Repl::Markdown
|
|
|
61
63
|
when :em
|
|
62
64
|
node.children.each { walk_collect(_1, Curses::A_UNDERLINE, chunks) }
|
|
63
65
|
when :codespan
|
|
64
|
-
chunks << Node.new(node.value,
|
|
66
|
+
chunks << Node.new(node.value, Color.green)
|
|
65
67
|
when :a
|
|
66
68
|
node.children.each { walk_collect(_1, Curses::A_UNDERLINE, chunks) }
|
|
69
|
+
when :typographic_sym, :smart_quote
|
|
70
|
+
chunks << Node.new(symbol(node), attrs)
|
|
67
71
|
else
|
|
68
72
|
node.children.each { walk_collect(_1, attrs, chunks) }
|
|
69
73
|
end
|
data/lib/llm/repl/markdown.rb
CHANGED
|
@@ -11,12 +11,32 @@ class LLM::Repl
|
|
|
11
11
|
require_relative "markdown/table"
|
|
12
12
|
include Table
|
|
13
13
|
|
|
14
|
+
##
|
|
15
|
+
# Kramdown goes a bit beyond a standard markdown
|
|
16
|
+
# parser by representing certain characters or
|
|
17
|
+
# character sequences as distinct node types that are
|
|
18
|
+
# represented by `:typographic_sym`, and `:smart_quote`.
|
|
19
|
+
#
|
|
20
|
+
# The node's value maps back to one of the keys in
|
|
21
|
+
# this Hash, and the values are unicode characters
|
|
22
|
+
# that provide a visual representation of the node.
|
|
23
|
+
#
|
|
24
|
+
# @api private
|
|
25
|
+
SYMBOLS = {
|
|
26
|
+
hellip: "…",
|
|
27
|
+
ndash: "–", mdash: "—",
|
|
28
|
+
laquo: "«", raquo: "»",
|
|
29
|
+
laquo_space: "« ", raquo_space: "» ",
|
|
30
|
+
lsquo: "‘", rsquo: "’",
|
|
31
|
+
ldquo: "“", rdquo: "”"
|
|
32
|
+
}
|
|
33
|
+
|
|
14
34
|
##
|
|
15
35
|
# @param [String] text
|
|
16
36
|
# @param [Integer] width
|
|
17
37
|
# @return [LLM::Repl::Markdown]
|
|
18
38
|
def initialize(text, width)
|
|
19
|
-
@doc = Kramdown::Document.new(text)
|
|
39
|
+
@doc = Kramdown::Document.new(fenced_code_blocks(text))
|
|
20
40
|
@width = width
|
|
21
41
|
@ast = []
|
|
22
42
|
end
|
|
@@ -65,17 +85,20 @@ class LLM::Repl
|
|
|
65
85
|
emit("\n\n", attrs)
|
|
66
86
|
when :header
|
|
67
87
|
emit("\n", attrs)
|
|
68
|
-
node.children.each { walk(_1, Curses::A_BOLD) }
|
|
88
|
+
node.children.each { walk(_1, Curses::A_BOLD | Color.white) }
|
|
69
89
|
emit("\n", attrs)
|
|
70
90
|
when :strong
|
|
71
|
-
node.children.each { walk(_1, Curses::A_BOLD) }
|
|
91
|
+
node.children.each { walk(_1, Curses::A_BOLD | Color.white) }
|
|
72
92
|
when :em
|
|
73
93
|
node.children.each { walk(_1, Curses::A_UNDERLINE) }
|
|
74
94
|
when :codespan
|
|
75
|
-
emit(node.value,
|
|
95
|
+
emit(node.value, Color.green)
|
|
76
96
|
when :codeblock
|
|
77
|
-
emit(node
|
|
97
|
+
emit("#{lang(node)}\n", Curses::A_BOLD | Color.white) unless lang(node).empty?
|
|
98
|
+
emit(node.value, Color.green)
|
|
78
99
|
emit("\n\n", attrs)
|
|
100
|
+
when :typographic_sym, :smart_quote
|
|
101
|
+
emit(symbol(node), attrs)
|
|
79
102
|
when :br
|
|
80
103
|
emit("\n", attrs)
|
|
81
104
|
when :ul, :ol
|
|
@@ -100,7 +123,7 @@ class LLM::Repl
|
|
|
100
123
|
emit("─" * @width, attrs)
|
|
101
124
|
emit("\n\n", attrs)
|
|
102
125
|
when :a
|
|
103
|
-
node.children.each { walk(_1, Curses::A_UNDERLINE) }
|
|
126
|
+
node.children.each { walk(_1, Curses::A_UNDERLINE | Color.green) }
|
|
104
127
|
when :img
|
|
105
128
|
emit("[image: #{node.attr["alt"]}]", attrs)
|
|
106
129
|
else
|
|
@@ -111,5 +134,32 @@ class LLM::Repl
|
|
|
111
134
|
def emit(text, attrs)
|
|
112
135
|
@ast.push(Node.new(text.to_s, attrs))
|
|
113
136
|
end
|
|
137
|
+
|
|
138
|
+
def symbol(node)
|
|
139
|
+
SYMBOLS[node.value]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
##
|
|
143
|
+
# Kramdown's native fenced-code syntax uses
|
|
144
|
+
# `~~~`, not the GitHub-style ``` fences that
|
|
145
|
+
# models commonly emit. Rewrite ```lang blocks
|
|
146
|
+
# so they are parsed as real code blocks instead
|
|
147
|
+
# of inline code spans. The language is preserved
|
|
148
|
+
# so Kramdown tags the block with a `language-*`
|
|
149
|
+
# class.
|
|
150
|
+
# @param [String] text
|
|
151
|
+
# @return [String]
|
|
152
|
+
def fenced_code_blocks(text)
|
|
153
|
+
text.gsub(/^```(\S*)\s*$/) { "~~~#{$1}" }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
##
|
|
157
|
+
# Extracts the language from a code block's
|
|
158
|
+
# `language-*` class.
|
|
159
|
+
# @param [Kramdown::Element] node
|
|
160
|
+
# @return [String]
|
|
161
|
+
def lang(node)
|
|
162
|
+
node.attr["class"].to_s.sub(/\Alanguage-/, "")
|
|
163
|
+
end
|
|
114
164
|
end
|
|
115
165
|
end
|