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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +505 -14
  3. data/README.md +484 -50
  4. data/bin/llm.rb +148 -0
  5. data/data/anthropic.json +206 -263
  6. data/data/bedrock.json +2138 -1860
  7. data/data/deepinfra.json +1003 -624
  8. data/data/deepseek.json +38 -34
  9. data/data/google.json +1079 -371
  10. data/data/mistral.json +448 -368
  11. data/data/moonshot.json +384 -0
  12. data/data/openai.json +974 -1343
  13. data/data/xai.json +154 -126
  14. data/data/zai.json +191 -191
  15. data/lib/llm/agent.rb +123 -20
  16. data/lib/llm/context.rb +71 -88
  17. data/lib/llm/cost.rb +23 -17
  18. data/lib/llm/error.rb +0 -8
  19. data/lib/llm/function/array.rb +3 -3
  20. data/lib/llm/function/async/task.rb +2 -0
  21. data/lib/llm/function/fiber/task.rb +2 -0
  22. data/lib/llm/function/fork/task.rb +2 -0
  23. data/lib/llm/function/ractor/task.rb +2 -0
  24. data/lib/llm/function/sequential/group.rb +4 -1
  25. data/lib/llm/function/sequential/task.rb +1 -1
  26. data/lib/llm/function/task.rb +4 -0
  27. data/lib/llm/function/thread/task.rb +2 -0
  28. data/lib/llm/function.rb +33 -6
  29. data/lib/llm/guard/loop.rb +89 -0
  30. data/lib/llm/guard/null.rb +19 -0
  31. data/lib/llm/guard.rb +61 -0
  32. data/lib/llm/provider.rb +36 -0
  33. data/lib/llm/providers/anthropic/stream_parser.rb +1 -0
  34. data/lib/llm/providers/anthropic.rb +2 -9
  35. data/lib/llm/providers/bedrock/request_adapter.rb +1 -1
  36. data/lib/llm/providers/bedrock/stream_parser.rb +1 -0
  37. data/lib/llm/providers/bedrock.rb +1 -8
  38. data/lib/llm/providers/google/stream_parser.rb +1 -0
  39. data/lib/llm/providers/google.rb +1 -8
  40. data/lib/llm/providers/mistral.rb +1 -1
  41. data/lib/llm/providers/moonshot.rb +76 -0
  42. data/lib/llm/providers/ollama.rb +2 -9
  43. data/lib/llm/providers/openai/responses/stream_parser.rb +1 -0
  44. data/lib/llm/providers/openai/responses.rb +7 -9
  45. data/lib/llm/providers/openai/stream_parser.rb +1 -0
  46. data/lib/llm/providers/openai.rb +4 -11
  47. data/lib/llm/repl/bar.rb +4 -3
  48. data/lib/llm/repl/{transcript.rb → buffer.rb} +69 -29
  49. data/lib/llm/repl/color.rb +78 -0
  50. data/lib/llm/repl/command.rb +12 -5
  51. data/lib/llm/repl/commands/compact.rb +2 -2
  52. data/lib/llm/repl/commands/help.rb +3 -5
  53. data/lib/llm/repl/input/char.rb +46 -0
  54. data/lib/llm/repl/input/row.rb +39 -0
  55. data/lib/llm/repl/input.rb +251 -66
  56. data/lib/llm/repl/markdown/table.rb +11 -3
  57. data/lib/llm/repl/markdown.rb +34 -8
  58. data/lib/llm/repl/node.rb +37 -0
  59. data/lib/llm/repl/status.rb +42 -7
  60. data/lib/llm/repl/stream.rb +18 -6
  61. data/lib/llm/repl/walker.rb +3 -2
  62. data/lib/llm/repl/window.rb +54 -35
  63. data/lib/llm/repl.rb +74 -32
  64. data/lib/llm/skill.rb +20 -4
  65. data/lib/llm/stream.rb +8 -7
  66. data/lib/llm/tool.rb +29 -0
  67. data/lib/llm/tools/{swap_text.rb → edit-file.rb} +3 -3
  68. data/lib/llm/tools/git.rb +3 -0
  69. data/lib/llm/tools/mkdir.rb +3 -0
  70. data/lib/llm/tools/rg.rb +3 -0
  71. data/lib/llm/tools/ruby.rb +46 -0
  72. data/lib/llm/tools/shell.rb +3 -0
  73. data/lib/llm/tracer/pretty_logger.rb +127 -0
  74. data/lib/llm/tracer.rb +1 -0
  75. data/lib/llm/transformer/null.rb +21 -0
  76. data/lib/llm/transformer.rb +55 -0
  77. data/lib/llm/version.rb +1 -1
  78. data/lib/llm.rb +12 -2
  79. data/llm.gemspec +9 -2
  80. data/resources/deepdive/advanced/cancellation.md +74 -0
  81. data/resources/deepdive/advanced/compaction.md +83 -0
  82. data/resources/deepdive/advanced/context.md +267 -0
  83. data/resources/deepdive/advanced/guard.md +371 -0
  84. data/resources/deepdive/advanced/tracer.md +180 -0
  85. data/resources/deepdive/advanced/transformer.md +67 -0
  86. data/resources/deepdive/advanced/transports.md +45 -0
  87. data/resources/deepdive/everything_else/audio.md +122 -0
  88. data/resources/deepdive/everything_else/cost.md +99 -0
  89. data/resources/deepdive/everything_else/images.md +89 -0
  90. data/resources/deepdive/everything_else/object.md +108 -0
  91. data/resources/deepdive/everything_else/ocr.md +48 -0
  92. data/resources/deepdive/fundamentals/agents.md +202 -0
  93. data/resources/deepdive/fundamentals/builtin_tools.md +191 -0
  94. data/resources/deepdive/fundamentals/concurrency.md +104 -0
  95. data/resources/deepdive/fundamentals/database.md +449 -0
  96. data/resources/deepdive/fundamentals/embeddings.md +157 -0
  97. data/resources/deepdive/fundamentals/repl.md +87 -0
  98. data/resources/deepdive/fundamentals/schema.md +61 -0
  99. data/resources/deepdive/fundamentals/skills.md +106 -0
  100. data/resources/deepdive/fundamentals/stream.md +110 -0
  101. data/resources/deepdive/fundamentals/tools.md +265 -0
  102. data/resources/deepdive/protocols/a2a.md +106 -0
  103. data/resources/deepdive/protocols/mcp.md +111 -0
  104. data/resources/deepdive.md +58 -1792
  105. metadata +51 -7
  106. data/lib/llm/loop_guard.rb +0 -107
@@ -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
- @buffer = +""
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
- @buffer = @walker.prev.dup
95
- @cursor = @buffer.size
100
+ set(text: @walker.prev.dup)
96
101
  :ctrl_p
97
102
  elsif CTRL[:N] == char
98
- @buffer = @walker.next.dup
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(@buffer.dup)
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
- insert(char)
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
- "#{prompt}#{@buffer}"
161
- end
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
- # split by newlines. The viewport follows the cursor
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
- chunks = to_s.split("\n", -1)
183
- chunks[@scroll, height] || []
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
- before = to_s[0...cursor]
193
- line = before.count("\n")
194
- col = cursor - (before.rindex("\n") || -1) - 1
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 = [0, @buffer.size].max
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
- @cursor = [@cursor - 1, 0].max
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
- @cursor = [@cursor + 1, @buffer.size].min
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
- @cursor = [0, @cursor + 1].max
245
+ move_right
226
246
  end
227
247
 
228
248
  ##
229
249
  # @return [void]
230
250
  def autocomplete
231
- return unless @buffer[0] == "/"
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(@buffer)
241
- if REPEATS[TAB] >= 1
242
- candidate = keys[keys.index(candidates[0]) + 1] || keys[0]
243
- else
244
- candidate = candidates[0]
245
- end
246
- @buffer = "/#{candidate}"
247
- @cursor = @buffer.size
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
- @copy = @buffer.slice(@cursor, @buffer.size)
254
- @buffer[@cursor, @buffer.size] = ""
255
- @cursor = @buffer.size
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
- @buffer[@cursor] = ""
262
- @cursor = [0, @cursor].max
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
- @buffer.insert(@cursor, @copy)
270
- @cursor += @copy.size
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
- @buffer.dup.tap do
277
- @buffer.clear
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 ||= 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
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
- if lines[-1].size >= Curses.cols
311
- @buffer.insert(@cursor, "\n")
312
- @cursor += 1
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
- return if @cursor <= 0
321
- @buffer.slice!(@cursor - 1)
322
- @cursor -= 1
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 << {text: node.value.to_s, attrs:}
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 << {text: node.value, attrs: Curses::A_REVERSE}
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
@@ -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<Hash>]
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, Curses::A_REVERSE)
95
+ emit(node.value, Color.green)
76
96
  when :codeblock
77
- emit(node.value, Curses::A_REVERSE)
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['alt']}]", attrs)
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({text: text.to_s, attrs:}.compact)
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