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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +716 -1826
  3. data/README.md +668 -253
  4. data/bin/llm.rb +156 -56
  5. data/data/alibaba.json +1999 -0
  6. data/data/anthropic.json +195 -252
  7. data/data/bedrock.json +2189 -1854
  8. data/data/deepinfra.json +1312 -728
  9. data/data/deepseek.json +42 -39
  10. data/data/google.json +1024 -404
  11. data/data/mistral.json +481 -401
  12. data/data/moonshot.json +384 -0
  13. data/data/openai.json +985 -1354
  14. data/data/xai.json +220 -115
  15. data/data/zai.json +166 -166
  16. data/docs/deepdive/advanced/cancellation.md +74 -0
  17. data/docs/deepdive/advanced/compaction.md +85 -0
  18. data/docs/deepdive/advanced/context.md +280 -0
  19. data/docs/deepdive/advanced/guard.md +371 -0
  20. data/docs/deepdive/advanced/transformer.md +67 -0
  21. data/docs/deepdive/advanced/transports.md +45 -0
  22. data/docs/deepdive/features/builtin_tools.md +191 -0
  23. data/docs/deepdive/features/concurrency.md +110 -0
  24. data/docs/deepdive/features/database.md +449 -0
  25. data/docs/deepdive/features/embeddings.md +157 -0
  26. data/docs/deepdive/features/repl.md +141 -0
  27. data/docs/deepdive/fundamentals/agents.md +253 -0
  28. data/docs/deepdive/fundamentals/providers.md +159 -0
  29. data/docs/deepdive/fundamentals/schema.md +61 -0
  30. data/docs/deepdive/fundamentals/skills.md +111 -0
  31. data/docs/deepdive/fundamentals/stream.md +143 -0
  32. data/docs/deepdive/fundamentals/tools.md +329 -0
  33. data/docs/deepdive/media/audio.md +122 -0
  34. data/docs/deepdive/media/images.md +89 -0
  35. data/docs/deepdive/media/ocr.md +48 -0
  36. data/docs/deepdive/protocols/a2a.md +106 -0
  37. data/docs/deepdive/protocols/mcp.md +111 -0
  38. data/docs/deepdive/reference/cost.md +109 -0
  39. data/docs/deepdive/reference/model_registry.md +271 -0
  40. data/docs/deepdive/reference/object.md +108 -0
  41. data/docs/deepdive/reference/tracer.md +187 -0
  42. data/{resources → docs}/deepdive.md +37 -23
  43. data/lib/llm/a2a/transport/http.rb +1 -1
  44. data/lib/llm/active_record/acts_as_llm.rb +19 -5
  45. data/lib/llm/agent.rb +107 -17
  46. data/lib/llm/context.rb +164 -134
  47. data/lib/llm/cost.rb +114 -49
  48. data/lib/llm/error.rb +7 -8
  49. data/lib/llm/function/array.rb +1 -1
  50. data/lib/llm/function/async/task.rb +2 -0
  51. data/lib/llm/function/fiber/task.rb +2 -0
  52. data/lib/llm/function/fork/task.rb +16 -1
  53. data/lib/llm/function/ractor/task.rb +2 -0
  54. data/lib/llm/function/sequential/group.rb +20 -10
  55. data/lib/llm/function/sequential/task.rb +2 -9
  56. data/lib/llm/function/task.rb +4 -0
  57. data/lib/llm/function/thread/task.rb +2 -0
  58. data/lib/llm/function.rb +33 -4
  59. data/lib/llm/guard/loop.rb +89 -0
  60. data/lib/llm/guard/null.rb +19 -0
  61. data/lib/llm/guard.rb +61 -0
  62. data/lib/llm/message.rb +5 -4
  63. data/lib/llm/provider.rb +43 -0
  64. data/lib/llm/providers/alibaba/error_handler.rb +34 -0
  65. data/lib/llm/providers/alibaba/request_adapter.rb +13 -0
  66. data/lib/llm/providers/alibaba.rb +93 -0
  67. data/lib/llm/providers/anthropic/stream_parser.rb +1 -0
  68. data/lib/llm/providers/anthropic.rb +1 -9
  69. data/lib/llm/providers/bedrock/stream_parser.rb +1 -0
  70. data/lib/llm/providers/bedrock.rb +9 -9
  71. data/lib/llm/providers/deepseek/request_adapter.rb +2 -33
  72. data/lib/llm/providers/google/stream_parser.rb +1 -0
  73. data/lib/llm/providers/google.rb +1 -9
  74. data/lib/llm/providers/moonshot.rb +76 -0
  75. data/lib/llm/providers/ollama.rb +1 -9
  76. data/lib/llm/providers/openai/responses/stream_parser.rb +1 -0
  77. data/lib/llm/providers/openai/responses.rb +6 -9
  78. data/lib/llm/providers/openai/schema.rb +37 -0
  79. data/lib/llm/providers/openai/stream_parser.rb +1 -0
  80. data/lib/llm/providers/openai.rb +4 -12
  81. data/lib/llm/registry/model.rb +186 -0
  82. data/lib/llm/registry.rb +45 -14
  83. data/lib/llm/repl/bar.rb +11 -12
  84. data/lib/llm/repl/buffer.rb +43 -16
  85. data/lib/llm/repl/color.rb +85 -0
  86. data/lib/llm/repl/command.rb +12 -0
  87. data/lib/llm/repl/commands/model.rb +39 -0
  88. data/lib/llm/repl/input/cache.rb +45 -0
  89. data/lib/llm/repl/input/char.rb +46 -0
  90. data/lib/llm/repl/input/row.rb +39 -0
  91. data/lib/llm/repl/input.rb +327 -79
  92. data/lib/llm/repl/markdown/table.rb +6 -2
  93. data/lib/llm/repl/markdown.rb +56 -6
  94. data/lib/llm/repl/node.rb +7 -0
  95. data/lib/llm/repl/status.rb +54 -5
  96. data/lib/llm/repl/stream.rb +16 -4
  97. data/lib/llm/repl/walker.rb +3 -2
  98. data/lib/llm/repl/window.rb +111 -11
  99. data/lib/llm/repl.rb +47 -21
  100. data/lib/llm/sequel/plugin.rb +19 -5
  101. data/lib/llm/skill.rb +21 -8
  102. data/lib/llm/stream.rb +35 -7
  103. data/lib/llm/tool.rb +27 -0
  104. data/lib/llm/tools/rg.rb +2 -1
  105. data/lib/llm/transformer/null.rb +21 -0
  106. data/lib/llm/transformer.rb +55 -0
  107. data/lib/llm/transport/curb.rb +23 -3
  108. data/lib/llm/usage.rb +155 -9
  109. data/lib/llm/version.rb +1 -1
  110. data/lib/llm.rb +121 -29
  111. data/llm.gemspec +16 -10
  112. metadata +100 -13
  113. data/lib/llm/loop_guard.rb +0 -107
@@ -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 = Curses::Key::UP
29
- DOWN = Curses::Key::DOWN
30
- LEFT = Curses::Key::LEFT
31
- RIGHT = Curses::Key::RIGHT
32
- PGUP = Curses::KEY_PPAGE
33
- PGDOWN = Curses::KEY_NPAGE
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
- @buffer = +""
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
- @buffer = @walker.prev.dup
95
- @cursor = @buffer.size
103
+ set(text: @walker.prev.dup)
96
104
  :ctrl_p
97
105
  elsif CTRL[:N] == char
98
- @buffer = @walker.next.dup
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 BACKSPACE == char
132
+ elsif char == BACKSPACE
126
133
  backspace
127
134
  :backspace
128
- elsif ENTER == char
135
+ elsif char == ENTER
129
136
  if @paste = is_paste.()
130
137
  insert("\n")
131
138
  :char
132
139
  else
133
- @memory.push(@buffer.dup)
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
- insert(char)
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
- "#{prompt}#{@buffer}"
161
- end
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
- # split by newlines. The viewport follows the cursor
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
- chunks = to_s.split("\n", -1)
183
- chunks[@scroll, height] || []
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
- before = to_s[0...cursor]
193
- line = before.count("\n")
194
- col = cursor - (before.rindex("\n") || -1) - 1
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 = [0, @buffer.size].max
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
- @cursor = [@cursor - 1, 0].max
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
- @cursor = [@cursor + 1, @buffer.size].min
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
- @cursor = [0, @cursor + 1].max
251
+ move_right
226
252
  end
227
253
 
228
254
  ##
229
255
  # @return [void]
230
256
  def autocomplete
231
- return unless @buffer[0] == "/"
257
+ return unless text[0] == "/"
232
258
  ##
233
- # This method implements a simple autocomplete
234
- # that supports cycling through all known
235
- # commands. When given tab in quick succession,
236
- # we cycle to the nearest neighbour for the last
237
- # full match. However, it's not based on similarity,
238
- # it's just the next element in the array.
239
- keys = LLM::Command.registry.keys
240
- candidates = LLM::Command.complete(@buffer)
241
- if REPEATS[TAB] >= 1
242
- candidate = keys[keys.index(candidates[0]) + 1] || keys[0]
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
- candidate = candidates[0]
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
- @copy = @buffer.slice(@cursor, @buffer.size)
254
- @buffer[@cursor, @buffer.size] = ""
255
- @cursor = @buffer.size
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
- @buffer[@cursor] = ""
262
- @cursor = [0, @cursor].max
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
- @buffer.insert(@cursor, @copy)
270
- @cursor += @copy.size
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
- @buffer.dup.tap do
277
- @buffer.clear
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 ||= to_s.split("\n", -1).size
296
- cursor_line = to_s[0...cursor].count("\n")
297
- if cursor_line < @scroll
298
- @scroll = cursor_line
299
- elsif cursor_line >= (@scroll + height)
300
- @scroll = (cursor_line - height) + 1
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
- if lines[-1].size >= Curses.cols
311
- @buffer.insert(@cursor, "\n")
312
- @cursor += 1
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
- return if @cursor <= 0
321
- @buffer.slice!(@cursor - 1)
322
- @cursor -= 1
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 = LLM::Repl::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, Curses::A_REVERSE)
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
@@ -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, Curses::A_REVERSE)
95
+ emit(node.value, Color.green)
76
96
  when :codeblock
77
- emit(node.value, Curses::A_REVERSE)
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