llm.rb 13.0.0 → 14.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 +505 -14
- data/README.md +484 -50
- data/bin/llm.rb +148 -0
- data/data/anthropic.json +206 -263
- data/data/bedrock.json +2138 -1860
- data/data/deepinfra.json +1003 -624
- data/data/deepseek.json +38 -34
- data/data/google.json +1079 -371
- data/data/mistral.json +448 -368
- data/data/moonshot.json +384 -0
- data/data/openai.json +974 -1343
- data/data/xai.json +154 -126
- data/data/zai.json +191 -191
- data/lib/llm/agent.rb +123 -20
- data/lib/llm/context.rb +71 -88
- data/lib/llm/cost.rb +23 -17
- data/lib/llm/error.rb +0 -8
- data/lib/llm/function/array.rb +3 -3
- 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 +2 -0
- data/lib/llm/function/ractor/task.rb +2 -0
- data/lib/llm/function/sequential/group.rb +4 -1
- data/lib/llm/function/sequential/task.rb +1 -1
- data/lib/llm/function/task.rb +4 -0
- data/lib/llm/function/thread/task.rb +2 -0
- data/lib/llm/function.rb +33 -6
- 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/provider.rb +36 -0
- data/lib/llm/providers/anthropic/stream_parser.rb +1 -0
- data/lib/llm/providers/anthropic.rb +2 -9
- data/lib/llm/providers/bedrock/request_adapter.rb +1 -1
- data/lib/llm/providers/bedrock/stream_parser.rb +1 -0
- data/lib/llm/providers/bedrock.rb +1 -8
- data/lib/llm/providers/google/stream_parser.rb +1 -0
- data/lib/llm/providers/google.rb +1 -8
- data/lib/llm/providers/mistral.rb +1 -1
- data/lib/llm/providers/moonshot.rb +76 -0
- data/lib/llm/providers/ollama.rb +2 -9
- data/lib/llm/providers/openai/responses/stream_parser.rb +1 -0
- data/lib/llm/providers/openai/responses.rb +7 -9
- data/lib/llm/providers/openai/stream_parser.rb +1 -0
- data/lib/llm/providers/openai.rb +4 -11
- data/lib/llm/repl/bar.rb +4 -3
- data/lib/llm/repl/{transcript.rb → buffer.rb} +69 -29
- data/lib/llm/repl/color.rb +78 -0
- data/lib/llm/repl/command.rb +12 -5
- data/lib/llm/repl/commands/compact.rb +2 -2
- data/lib/llm/repl/commands/help.rb +3 -5
- data/lib/llm/repl/input/char.rb +46 -0
- data/lib/llm/repl/input/row.rb +39 -0
- data/lib/llm/repl/input.rb +251 -66
- data/lib/llm/repl/markdown/table.rb +11 -3
- data/lib/llm/repl/markdown.rb +34 -8
- data/lib/llm/repl/node.rb +37 -0
- data/lib/llm/repl/status.rb +42 -7
- data/lib/llm/repl/stream.rb +18 -6
- data/lib/llm/repl/walker.rb +3 -2
- data/lib/llm/repl/window.rb +54 -35
- data/lib/llm/repl.rb +74 -32
- data/lib/llm/skill.rb +20 -4
- data/lib/llm/stream.rb +8 -7
- data/lib/llm/tool.rb +29 -0
- data/lib/llm/tools/{swap_text.rb → edit-file.rb} +3 -3
- data/lib/llm/tools/git.rb +3 -0
- data/lib/llm/tools/mkdir.rb +3 -0
- data/lib/llm/tools/rg.rb +3 -0
- data/lib/llm/tools/ruby.rb +46 -0
- data/lib/llm/tools/shell.rb +3 -0
- data/lib/llm/tracer/pretty_logger.rb +127 -0
- data/lib/llm/tracer.rb +1 -0
- data/lib/llm/transformer/null.rb +21 -0
- data/lib/llm/transformer.rb +55 -0
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +12 -2
- data/llm.gemspec +9 -2
- data/resources/deepdive/advanced/cancellation.md +74 -0
- data/resources/deepdive/advanced/compaction.md +83 -0
- data/resources/deepdive/advanced/context.md +267 -0
- data/resources/deepdive/advanced/guard.md +371 -0
- data/resources/deepdive/advanced/tracer.md +180 -0
- data/resources/deepdive/advanced/transformer.md +67 -0
- data/resources/deepdive/advanced/transports.md +45 -0
- data/resources/deepdive/everything_else/audio.md +122 -0
- data/resources/deepdive/everything_else/cost.md +99 -0
- data/resources/deepdive/everything_else/images.md +89 -0
- data/resources/deepdive/everything_else/object.md +108 -0
- data/resources/deepdive/everything_else/ocr.md +48 -0
- data/resources/deepdive/fundamentals/agents.md +202 -0
- data/resources/deepdive/fundamentals/builtin_tools.md +191 -0
- data/resources/deepdive/fundamentals/concurrency.md +104 -0
- data/resources/deepdive/fundamentals/database.md +449 -0
- data/resources/deepdive/fundamentals/embeddings.md +157 -0
- data/resources/deepdive/fundamentals/repl.md +87 -0
- data/resources/deepdive/fundamentals/schema.md +61 -0
- data/resources/deepdive/fundamentals/skills.md +106 -0
- data/resources/deepdive/fundamentals/stream.md +110 -0
- data/resources/deepdive/fundamentals/tools.md +265 -0
- data/resources/deepdive/protocols/a2a.md +106 -0
- data/resources/deepdive/protocols/mcp.md +111 -0
- data/resources/deepdive.md +58 -1792
- metadata +51 -7
- data/lib/llm/loop_guard.rb +0 -107
data/lib/llm/repl/input.rb
CHANGED
|
@@ -6,6 +6,9 @@ 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
|
+
|
|
9
12
|
CTRL = {
|
|
10
13
|
A: Curses::KEY_CTRL_A,
|
|
11
14
|
E: Curses::KEY_CTRL_E,
|
|
@@ -44,10 +47,6 @@ class LLM::Repl
|
|
|
44
47
|
# distinguishes a paste from manual typing.
|
|
45
48
|
PASTE_THRESHOLD = 0.05
|
|
46
49
|
|
|
47
|
-
##
|
|
48
|
-
# @return [String]
|
|
49
|
-
attr_reader :buffer
|
|
50
|
-
|
|
51
50
|
##
|
|
52
51
|
# @param [Boolean] value
|
|
53
52
|
# @return [void]
|
|
@@ -60,8 +59,8 @@ class LLM::Repl
|
|
|
60
59
|
@name = repl.name
|
|
61
60
|
@agent = repl.agent
|
|
62
61
|
@provider = @agent.llm.name
|
|
63
|
-
@
|
|
64
|
-
@cursor = 0
|
|
62
|
+
@rows = [Row.new]
|
|
63
|
+
@cursor = [0, 0]
|
|
65
64
|
@scroll = 0
|
|
66
65
|
@height = options.fetch(:height, 3)
|
|
67
66
|
@last_char_at = nil
|
|
@@ -70,6 +69,13 @@ class LLM::Repl
|
|
|
70
69
|
@paste = false
|
|
71
70
|
end
|
|
72
71
|
|
|
72
|
+
##
|
|
73
|
+
# @return [String]
|
|
74
|
+
# The input buffer as an ordinary string.
|
|
75
|
+
def buffer
|
|
76
|
+
text
|
|
77
|
+
end
|
|
78
|
+
|
|
73
79
|
##
|
|
74
80
|
# @param [LLM::Repl::Window] window
|
|
75
81
|
# @param [Object] char
|
|
@@ -91,12 +97,10 @@ class LLM::Repl
|
|
|
91
97
|
elsif ESC == char
|
|
92
98
|
@agent.cancel!
|
|
93
99
|
elsif CTRL[:P] == char
|
|
94
|
-
|
|
95
|
-
@cursor = @buffer.size
|
|
100
|
+
set(text: @walker.prev.dup)
|
|
96
101
|
:ctrl_p
|
|
97
102
|
elsif CTRL[:N] == char
|
|
98
|
-
|
|
99
|
-
@cursor = @buffer.size
|
|
103
|
+
set(text: @walker.next.dup)
|
|
100
104
|
:ctrl_n
|
|
101
105
|
elsif CTRL[:D] == char
|
|
102
106
|
delete
|
|
@@ -130,7 +134,7 @@ class LLM::Repl
|
|
|
130
134
|
insert("\n")
|
|
131
135
|
:char
|
|
132
136
|
else
|
|
133
|
-
@memory.push(
|
|
137
|
+
@memory.push(text)
|
|
134
138
|
@walker.cursor = @memory.size
|
|
135
139
|
:submit
|
|
136
140
|
end
|
|
@@ -141,7 +145,16 @@ class LLM::Repl
|
|
|
141
145
|
window.scroll_down
|
|
142
146
|
:down
|
|
143
147
|
elsif String === char
|
|
144
|
-
|
|
148
|
+
##
|
|
149
|
+
# A paste arrives as a burst of fast characters. Flag it so the
|
|
150
|
+
# next loop iteration drains the rest through read_paste in a
|
|
151
|
+
# single shot, instead of inserting one character at a time and
|
|
152
|
+
# redrawing after each. A string that is already the product of
|
|
153
|
+
# read_paste (multi-character) is inserted as-is, one char at a
|
|
154
|
+
# time, so every newline creates a row and every Char stays a
|
|
155
|
+
# single character.
|
|
156
|
+
@paste = true if char.length == 1 && is_paste.()
|
|
157
|
+
char.each_char { |c| insert(c) }
|
|
145
158
|
:char
|
|
146
159
|
else
|
|
147
160
|
nil
|
|
@@ -157,13 +170,10 @@ class LLM::Repl
|
|
|
157
170
|
##
|
|
158
171
|
# @return [String]
|
|
159
172
|
def to_s
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
# @return [Integer]
|
|
165
|
-
def cursor
|
|
166
|
-
prompt.size + @cursor
|
|
173
|
+
@rows
|
|
174
|
+
.each_with_index
|
|
175
|
+
.map { |row, i| (i.zero? ? prompt : "") + row.to_s }
|
|
176
|
+
.join("\n")
|
|
167
177
|
end
|
|
168
178
|
|
|
169
179
|
##
|
|
@@ -173,14 +183,15 @@ class LLM::Repl
|
|
|
173
183
|
end
|
|
174
184
|
|
|
175
185
|
##
|
|
176
|
-
# Returns the visible lines of the input buffer
|
|
177
|
-
#
|
|
178
|
-
# so the cursor line is always visible.
|
|
186
|
+
# Returns the visible lines of the input buffer. The viewport
|
|
187
|
+
# follows the cursor so the cursor line is always visible.
|
|
179
188
|
# @return [Array<String>]
|
|
180
189
|
def lines
|
|
181
190
|
scroll!
|
|
182
|
-
|
|
183
|
-
|
|
191
|
+
visible = @rows[@scroll, height] || []
|
|
192
|
+
visible.each_with_index.map do |row, i|
|
|
193
|
+
(i.zero? && @scroll.zero? ? prompt : "") + row.to_s
|
|
194
|
+
end
|
|
184
195
|
end
|
|
185
196
|
|
|
186
197
|
##
|
|
@@ -189,46 +200,55 @@ class LLM::Repl
|
|
|
189
200
|
# @return [Array(Integer, Integer)]
|
|
190
201
|
def cursor_pos
|
|
191
202
|
scroll!
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
[line - @scroll, col]
|
|
203
|
+
row, col = @cursor
|
|
204
|
+
col += prompt.size if row.zero?
|
|
205
|
+
[row - @scroll, col]
|
|
196
206
|
end
|
|
197
207
|
|
|
198
208
|
##
|
|
199
209
|
# @return [void]
|
|
200
210
|
def move_start
|
|
201
|
-
@cursor = 0
|
|
211
|
+
@cursor = [0, 0]
|
|
202
212
|
end
|
|
203
213
|
|
|
204
214
|
##
|
|
205
215
|
# @return [void]
|
|
206
216
|
def move_end
|
|
207
|
-
@cursor = [
|
|
217
|
+
@cursor = [@rows.size - 1, @rows.last.chars.size]
|
|
208
218
|
end
|
|
209
219
|
|
|
210
220
|
##
|
|
211
221
|
# @return [void]
|
|
212
222
|
def move_left
|
|
213
|
-
|
|
223
|
+
row, col = @cursor
|
|
224
|
+
@cursor =
|
|
225
|
+
if col > 0 then [row, col - 1]
|
|
226
|
+
elsif row > 0 then [row - 1, @rows[row - 1].chars.size]
|
|
227
|
+
else [0, 0]
|
|
228
|
+
end
|
|
214
229
|
end
|
|
215
230
|
|
|
216
231
|
##
|
|
217
232
|
# @return [void]
|
|
218
233
|
def move_right
|
|
219
|
-
|
|
234
|
+
row, col = @cursor
|
|
235
|
+
@cursor =
|
|
236
|
+
if col < @rows[row].chars.size then [row, col + 1]
|
|
237
|
+
elsif row < @rows.size - 1 then [row + 1, 0]
|
|
238
|
+
else [row, col]
|
|
239
|
+
end
|
|
220
240
|
end
|
|
221
241
|
|
|
222
242
|
##
|
|
223
243
|
# @return [void]
|
|
224
244
|
def move_forward
|
|
225
|
-
|
|
245
|
+
move_right
|
|
226
246
|
end
|
|
227
247
|
|
|
228
248
|
##
|
|
229
249
|
# @return [void]
|
|
230
250
|
def autocomplete
|
|
231
|
-
return unless
|
|
251
|
+
return unless text[0] == "/"
|
|
232
252
|
##
|
|
233
253
|
# This method implements a simple autocomplete
|
|
234
254
|
# that supports cycling through all known
|
|
@@ -237,45 +257,72 @@ class LLM::Repl
|
|
|
237
257
|
# full match. However, it's not based on similarity,
|
|
238
258
|
# it's just the next element in the array.
|
|
239
259
|
keys = LLM::Command.registry.keys
|
|
240
|
-
candidates = LLM::Command.complete(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
260
|
+
candidates = LLM::Command.complete(text)
|
|
261
|
+
candidate =
|
|
262
|
+
if REPEATS[TAB] >= 1
|
|
263
|
+
keys[keys.index(candidates[0]) + 1] || keys[0]
|
|
264
|
+
else
|
|
265
|
+
candidates[0]
|
|
266
|
+
end
|
|
267
|
+
set(text: "/#{candidate}")
|
|
248
268
|
end
|
|
249
269
|
|
|
250
270
|
##
|
|
251
271
|
# @return [void]
|
|
252
272
|
def kill
|
|
253
|
-
|
|
254
|
-
@
|
|
255
|
-
@
|
|
273
|
+
row, col = @cursor
|
|
274
|
+
tail = @rows[row].chars[col..].map(&:to_s).join
|
|
275
|
+
tail += @rows[(row + 1)..].map(&:to_s).join("\n")
|
|
276
|
+
@copy = tail
|
|
277
|
+
@rows[row].chars.slice!(col..)
|
|
278
|
+
@rows = @rows[0..row]
|
|
279
|
+
@cursor = [row, col]
|
|
256
280
|
end
|
|
257
281
|
|
|
258
282
|
##
|
|
283
|
+
# Deletes the character at the cursor, or consumes
|
|
284
|
+
# the break and pulls up the next row at the end of
|
|
285
|
+
# a row.
|
|
259
286
|
# @return [void]
|
|
260
287
|
def delete
|
|
261
|
-
|
|
262
|
-
|
|
288
|
+
row, col = @cursor
|
|
289
|
+
if col < @rows[row].chars.size
|
|
290
|
+
@rows[row].chars.delete_at(col)
|
|
291
|
+
elsif row < @rows.size - 1
|
|
292
|
+
nxt = @rows[row + 1]
|
|
293
|
+
##
|
|
294
|
+
# A wrapped row was split at a space; restore it so
|
|
295
|
+
# the merged words don't run together.
|
|
296
|
+
@rows[row].chars << Char.new(" ") if nxt.break_type == :space
|
|
297
|
+
@rows[row].chars.concat(nxt.chars)
|
|
298
|
+
@rows.delete_at(row + 1)
|
|
299
|
+
end
|
|
263
300
|
end
|
|
264
301
|
|
|
265
302
|
##
|
|
266
303
|
# @return [void]
|
|
267
304
|
def restore
|
|
268
305
|
return unless @copy
|
|
269
|
-
|
|
270
|
-
@
|
|
306
|
+
row, col = @cursor
|
|
307
|
+
@copy.each_char do |char|
|
|
308
|
+
if char == "\n"
|
|
309
|
+
@rows.insert(row + 1, Row.new(:newline))
|
|
310
|
+
row += 1
|
|
311
|
+
col = 0
|
|
312
|
+
else
|
|
313
|
+
@rows[row].chars.insert(col, Char.new(char))
|
|
314
|
+
col += 1
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
@cursor = [row, col]
|
|
271
318
|
end
|
|
272
319
|
|
|
273
320
|
##
|
|
274
321
|
# @return [String]
|
|
275
322
|
def take
|
|
276
|
-
|
|
277
|
-
@
|
|
278
|
-
@cursor = 0
|
|
323
|
+
text.tap do
|
|
324
|
+
@rows = [Row.new]
|
|
325
|
+
@cursor = [0, 0]
|
|
279
326
|
@scroll = 0
|
|
280
327
|
end
|
|
281
328
|
end
|
|
@@ -292,12 +339,12 @@ class LLM::Repl
|
|
|
292
339
|
# Adjusts @scroll so the cursor line is visible within
|
|
293
340
|
# the viewport.
|
|
294
341
|
def scroll!(total_lines = nil)
|
|
295
|
-
total_lines ||=
|
|
296
|
-
|
|
297
|
-
if
|
|
298
|
-
@scroll =
|
|
299
|
-
elsif
|
|
300
|
-
@scroll = (
|
|
342
|
+
total_lines ||= @rows.size
|
|
343
|
+
cursor_row = @cursor[0]
|
|
344
|
+
if cursor_row < @scroll
|
|
345
|
+
@scroll = cursor_row
|
|
346
|
+
elsif cursor_row >= (@scroll + height)
|
|
347
|
+
@scroll = (cursor_row - height) + 1
|
|
301
348
|
end
|
|
302
349
|
@scroll = [[@scroll, (total_lines - height)].min, 0].max
|
|
303
350
|
end
|
|
@@ -306,20 +353,158 @@ class LLM::Repl
|
|
|
306
353
|
"#{@provider}(#{@name})> "
|
|
307
354
|
end
|
|
308
355
|
|
|
356
|
+
##
|
|
357
|
+
# Returns the line the cursor is on, including the prompt.
|
|
358
|
+
# @return [String]
|
|
359
|
+
def current_line
|
|
360
|
+
row = @cursor[0]
|
|
361
|
+
(row.zero? ? prompt : "") + @rows[row].to_s
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
##
|
|
365
|
+
# Inserts a character at the cursor, wrapping the current row at
|
|
366
|
+
# the terminal width by starting a new row. A word that would be
|
|
367
|
+
# cut in half is moved whole onto the new row.
|
|
368
|
+
# @param [String] char
|
|
369
|
+
# @return [void]
|
|
309
370
|
def insert(char)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
@
|
|
371
|
+
row, col = @cursor
|
|
372
|
+
if char == "\n"
|
|
373
|
+
@rows.insert(row + 1, Row.new(:newline))
|
|
374
|
+
@cursor = [row + 1, 0]
|
|
375
|
+
elsif current_line.size >= Curses.cols
|
|
376
|
+
if char == " "
|
|
377
|
+
@rows.insert(row + 1, Row.new(:space))
|
|
378
|
+
@cursor = [row + 1, 0]
|
|
379
|
+
elsif (index = current_line.rindex(" "))
|
|
380
|
+
offset = row.zero? ? prompt.size : 0
|
|
381
|
+
split = index - offset
|
|
382
|
+
tail = @rows[row].chars.slice!((split + 1)..) || []
|
|
383
|
+
@rows[row].chars.delete_at(split)
|
|
384
|
+
@rows.insert(row + 1, Row.new(:space))
|
|
385
|
+
@rows[row + 1].chars.concat(tail)
|
|
386
|
+
@rows[row + 1].chars << Char.new(char)
|
|
387
|
+
@cursor = [row + 1, @rows[row + 1].chars.size]
|
|
388
|
+
else
|
|
389
|
+
@rows.insert(row + 1, Row.new(:space))
|
|
390
|
+
@rows[row + 1].chars << Char.new(char)
|
|
391
|
+
@cursor = [row + 1, 1]
|
|
392
|
+
end
|
|
393
|
+
else
|
|
394
|
+
@rows[row].chars.insert(col, Char.new(char))
|
|
395
|
+
@cursor = [row, col + char.length]
|
|
313
396
|
end
|
|
314
|
-
@buffer.insert(@cursor, char)
|
|
315
|
-
@cursor += char.length
|
|
316
397
|
scroll!
|
|
317
398
|
end
|
|
318
399
|
|
|
400
|
+
##
|
|
401
|
+
# @return [void]
|
|
319
402
|
def backspace
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
403
|
+
row, col = @cursor
|
|
404
|
+
if col > 0
|
|
405
|
+
@rows[row].chars.delete_at(col - 1)
|
|
406
|
+
@cursor = [row, col - 1]
|
|
407
|
+
elsif row > 0
|
|
408
|
+
prev = @rows[row - 1]
|
|
409
|
+
prev.chars.concat(@rows[row].chars)
|
|
410
|
+
@rows.delete_at(row)
|
|
411
|
+
@cursor = [row - 1, prev.chars.size]
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
##
|
|
416
|
+
# Flattens the rows into a single string. Each row joins the one
|
|
417
|
+
# before it using its own `break_type`: a row started by an
|
|
418
|
+
# auto-wrap joins with a space, a row started by a real newline
|
|
419
|
+
# joins with a newline.
|
|
420
|
+
# @return [String]
|
|
421
|
+
def text
|
|
422
|
+
out = +""
|
|
423
|
+
@rows.each_with_index do |row, i|
|
|
424
|
+
out << row.to_s
|
|
425
|
+
nxt = @rows[i + 1]
|
|
426
|
+
if nxt
|
|
427
|
+
out << (nxt.break_type == :newline ? "\n" : " ")
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
out
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
##
|
|
434
|
+
# Feeds one character into the row model: newlines
|
|
435
|
+
# start a row, overflowing rows wrap at a word.
|
|
436
|
+
# @param [String] char
|
|
437
|
+
# @return [void]
|
|
438
|
+
def feed(char)
|
|
439
|
+
if char == "\n"
|
|
440
|
+
##
|
|
441
|
+
# A real newline is a row boundary in the model,
|
|
442
|
+
# not a character, so it becomes a new row.
|
|
443
|
+
@rows << Row.new(:newline)
|
|
444
|
+
return
|
|
445
|
+
end
|
|
446
|
+
row = @rows.last
|
|
447
|
+
##
|
|
448
|
+
# Only the first row shares its columns with the prompt,
|
|
449
|
+
# so it has fewer columns left for text.
|
|
450
|
+
width = Curses.cols - (row.equal?(@rows.first) ? prompt.size : 0)
|
|
451
|
+
if row.chars.any? and row.to_s.length + 1 > width
|
|
452
|
+
wrap(row)
|
|
453
|
+
end
|
|
454
|
+
@rows.last.chars << Char.new(char)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
##
|
|
458
|
+
# Moves the word tail of a full row onto a new
|
|
459
|
+
# continuation row, keeping the overflow word whole.
|
|
460
|
+
# @param [LLM::Repl::Input::Row] row
|
|
461
|
+
# @return [void]
|
|
462
|
+
def wrap(row)
|
|
463
|
+
index = row.to_s.rindex(" ")
|
|
464
|
+
if index
|
|
465
|
+
##
|
|
466
|
+
# Move everything after the last space down and
|
|
467
|
+
# drop the space; break_type restores it on flatten.
|
|
468
|
+
tail = row.chars.slice!((index + 1)..) || []
|
|
469
|
+
row.chars.pop
|
|
470
|
+
@rows << Row.new(:space).tap { |r| r.chars.concat(tail) }
|
|
471
|
+
else
|
|
472
|
+
##
|
|
473
|
+
# A single unbroken word longer than the width hard-breaks.
|
|
474
|
+
@rows << Row.new(:space)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
##
|
|
479
|
+
# Replaces the input with the given text, reflowing it into rows
|
|
480
|
+
# at the terminal width. The cursor ends at the last row.
|
|
481
|
+
# @param [String] text
|
|
482
|
+
# @return [void]
|
|
483
|
+
def set(text:)
|
|
484
|
+
return if noop_set?(text)
|
|
485
|
+
@rows = [Row.new]
|
|
486
|
+
text.each_char { |char| feed(char) }
|
|
487
|
+
##
|
|
488
|
+
# A trailing newline leaves an empty last row; drop it
|
|
489
|
+
# so the cursor rests at the end of the content row.
|
|
490
|
+
@rows.pop while @rows.size > 1 and @rows.last.empty?
|
|
491
|
+
@cursor = [@rows.size - 1, @rows.last.chars.size]
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
##
|
|
495
|
+
# Returns true when the {#set} method should return early.
|
|
496
|
+
# @param [String, nil] str
|
|
497
|
+
# The new string to set.
|
|
498
|
+
# @return [Boolean]
|
|
499
|
+
def noop_set?(str)
|
|
500
|
+
if str.nil?
|
|
501
|
+
true
|
|
502
|
+
elsif @rows.size == 1
|
|
503
|
+
chars = @rows[0].to_s
|
|
504
|
+
str.empty? and chars.empty?
|
|
505
|
+
else
|
|
506
|
+
false
|
|
507
|
+
end
|
|
323
508
|
end
|
|
324
509
|
end
|
|
325
510
|
end
|
|
@@ -4,6 +4,13 @@ class LLM::Repl::Markdown
|
|
|
4
4
|
##
|
|
5
5
|
# Renders Kramdown `:table` nodes as aligned columns.
|
|
6
6
|
module Table
|
|
7
|
+
##
|
|
8
|
+
# @api private
|
|
9
|
+
Node = LLM::Repl::Node
|
|
10
|
+
Color = LLM::Repl::Color
|
|
11
|
+
private_constant :Node
|
|
12
|
+
private_constant :Color
|
|
13
|
+
|
|
7
14
|
##
|
|
8
15
|
# Renders a table node by collecting all cells first to
|
|
9
16
|
# compute column widths, then emitting each row with
|
|
@@ -15,7 +22,6 @@ class LLM::Repl::Markdown
|
|
|
15
22
|
rows.each do |row|
|
|
16
23
|
emit("| ", attrs)
|
|
17
24
|
row.each_with_index do |chunks, i|
|
|
18
|
-
text = chunks.map { _1[:text] }.join
|
|
19
25
|
width = widths[i]
|
|
20
26
|
chunks.each { |c| emit(c[:text].ljust(width), c[:attrs]) }
|
|
21
27
|
emit(" | ", attrs) unless i == row.size - 1
|
|
@@ -51,15 +57,17 @@ class LLM::Repl::Markdown
|
|
|
51
57
|
def walk_collect(node, attrs, chunks)
|
|
52
58
|
case node.type
|
|
53
59
|
when :text
|
|
54
|
-
chunks <<
|
|
60
|
+
chunks << Node.new(node.value.to_s, attrs)
|
|
55
61
|
when :strong
|
|
56
62
|
node.children.each { walk_collect(_1, Curses::A_BOLD, chunks) }
|
|
57
63
|
when :em
|
|
58
64
|
node.children.each { walk_collect(_1, Curses::A_UNDERLINE, chunks) }
|
|
59
65
|
when :codespan
|
|
60
|
-
chunks <<
|
|
66
|
+
chunks << Node.new(node.value, Color.green)
|
|
61
67
|
when :a
|
|
62
68
|
node.children.each { walk_collect(_1, Curses::A_UNDERLINE, chunks) }
|
|
69
|
+
when :typographic_sym, :smart_quote
|
|
70
|
+
chunks << Node.new(symbol(node), attrs)
|
|
63
71
|
else
|
|
64
72
|
node.children.each { walk_collect(_1, attrs, chunks) }
|
|
65
73
|
end
|
data/lib/llm/repl/markdown.rb
CHANGED
|
@@ -11,6 +11,26 @@ 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
|
|
@@ -22,7 +42,7 @@ class LLM::Repl
|
|
|
22
42
|
end
|
|
23
43
|
|
|
24
44
|
##
|
|
25
|
-
# @return [Array<
|
|
45
|
+
# @return [Array<Node>]
|
|
26
46
|
def ast
|
|
27
47
|
@ast.tap do
|
|
28
48
|
##
|
|
@@ -65,17 +85,19 @@ 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.value,
|
|
97
|
+
emit(node.value, Color.green)
|
|
78
98
|
emit("\n\n", attrs)
|
|
99
|
+
when :typographic_sym, :smart_quote
|
|
100
|
+
emit(symbol(node), attrs)
|
|
79
101
|
when :br
|
|
80
102
|
emit("\n", attrs)
|
|
81
103
|
when :ul, :ol
|
|
@@ -100,16 +122,20 @@ class LLM::Repl
|
|
|
100
122
|
emit("─" * @width, attrs)
|
|
101
123
|
emit("\n\n", attrs)
|
|
102
124
|
when :a
|
|
103
|
-
node.children.each { walk(_1, Curses::A_UNDERLINE) }
|
|
125
|
+
node.children.each { walk(_1, Curses::A_UNDERLINE | Color.green) }
|
|
104
126
|
when :img
|
|
105
|
-
emit("[image: #{node.attr[
|
|
127
|
+
emit("[image: #{node.attr["alt"]}]", attrs)
|
|
106
128
|
else
|
|
107
129
|
node.children.each { walk(_1, attrs) }
|
|
108
130
|
end
|
|
109
131
|
end
|
|
110
132
|
|
|
111
133
|
def emit(text, attrs)
|
|
112
|
-
@ast.push(
|
|
134
|
+
@ast.push(Node.new(text.to_s, attrs))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def symbol(node)
|
|
138
|
+
SYMBOLS[node.value]
|
|
113
139
|
end
|
|
114
140
|
end
|
|
115
141
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::Repl
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::Repl::Node LLM::Repl::Node} class wraps a piece
|
|
6
|
+
# of text and optional curses attributes.
|
|
7
|
+
# @api private
|
|
8
|
+
class Node
|
|
9
|
+
##
|
|
10
|
+
# @return [String]
|
|
11
|
+
attr_reader :text
|
|
12
|
+
|
|
13
|
+
##
|
|
14
|
+
# @return [Integer, nil]
|
|
15
|
+
attr_reader :attrs
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
# @param [String] text
|
|
19
|
+
# @param [Integer, nil] attrs
|
|
20
|
+
# @return [LLM::Repl::Node]
|
|
21
|
+
def initialize(text, attrs = nil)
|
|
22
|
+
@text = text.to_s
|
|
23
|
+
@attrs = attrs
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
##
|
|
27
|
+
# Hash-like lookup.
|
|
28
|
+
# @param [Symbol] key
|
|
29
|
+
# @return [String, Integer, nil]
|
|
30
|
+
def [](key)
|
|
31
|
+
case key
|
|
32
|
+
when :text then @text
|
|
33
|
+
when :attrs then @attrs
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|