llm.rb 13.1.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +320 -0
  3. data/README.md +340 -31
  4. data/bin/llm.rb +36 -12
  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 +47 -14
  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/async/task.rb +2 -0
  20. data/lib/llm/function/fiber/task.rb +2 -0
  21. data/lib/llm/function/fork/task.rb +2 -0
  22. data/lib/llm/function/ractor/task.rb +2 -0
  23. data/lib/llm/function/sequential/group.rb +4 -1
  24. data/lib/llm/function/sequential/task.rb +1 -1
  25. data/lib/llm/function/task.rb +4 -0
  26. data/lib/llm/function/thread/task.rb +2 -0
  27. data/lib/llm/function.rb +32 -4
  28. data/lib/llm/guard/loop.rb +89 -0
  29. data/lib/llm/guard/null.rb +19 -0
  30. data/lib/llm/guard.rb +61 -0
  31. data/lib/llm/provider.rb +36 -0
  32. data/lib/llm/providers/anthropic/stream_parser.rb +1 -0
  33. data/lib/llm/providers/anthropic.rb +1 -8
  34. data/lib/llm/providers/bedrock/stream_parser.rb +1 -0
  35. data/lib/llm/providers/bedrock.rb +1 -8
  36. data/lib/llm/providers/google/stream_parser.rb +1 -0
  37. data/lib/llm/providers/google.rb +1 -8
  38. data/lib/llm/providers/moonshot.rb +76 -0
  39. data/lib/llm/providers/ollama.rb +1 -8
  40. data/lib/llm/providers/openai/responses/stream_parser.rb +1 -0
  41. data/lib/llm/providers/openai/responses.rb +6 -8
  42. data/lib/llm/providers/openai/stream_parser.rb +1 -0
  43. data/lib/llm/providers/openai.rb +3 -10
  44. data/lib/llm/repl/bar.rb +4 -3
  45. data/lib/llm/repl/buffer.rb +42 -15
  46. data/lib/llm/repl/color.rb +78 -0
  47. data/lib/llm/repl/input/char.rb +46 -0
  48. data/lib/llm/repl/input/row.rb +39 -0
  49. data/lib/llm/repl/input.rb +251 -66
  50. data/lib/llm/repl/markdown/table.rb +6 -2
  51. data/lib/llm/repl/markdown.rb +31 -5
  52. data/lib/llm/repl/status.rb +38 -3
  53. data/lib/llm/repl/stream.rb +16 -4
  54. data/lib/llm/repl/walker.rb +3 -2
  55. data/lib/llm/repl/window.rb +25 -5
  56. data/lib/llm/repl.rb +29 -13
  57. data/lib/llm/stream.rb +8 -7
  58. data/lib/llm/tool.rb +29 -0
  59. data/lib/llm/transformer/null.rb +21 -0
  60. data/lib/llm/transformer.rb +55 -0
  61. data/lib/llm/version.rb +1 -1
  62. data/lib/llm.rb +12 -2
  63. data/llm.gemspec +1 -0
  64. data/resources/deepdive/advanced/cancellation.md +74 -0
  65. data/resources/deepdive/advanced/compaction.md +83 -0
  66. data/resources/deepdive/advanced/context.md +267 -0
  67. data/resources/deepdive/advanced/guard.md +371 -0
  68. data/resources/deepdive/advanced/tracer.md +180 -0
  69. data/resources/deepdive/advanced/transformer.md +67 -0
  70. data/resources/deepdive/advanced/transports.md +45 -0
  71. data/resources/deepdive/everything_else/audio.md +122 -0
  72. data/resources/deepdive/everything_else/cost.md +99 -0
  73. data/resources/deepdive/everything_else/images.md +89 -0
  74. data/resources/deepdive/everything_else/object.md +108 -0
  75. data/resources/deepdive/everything_else/ocr.md +48 -0
  76. data/resources/deepdive/fundamentals/agents.md +202 -0
  77. data/resources/deepdive/fundamentals/builtin_tools.md +191 -0
  78. data/resources/deepdive/fundamentals/concurrency.md +104 -0
  79. data/resources/deepdive/fundamentals/database.md +449 -0
  80. data/resources/deepdive/fundamentals/embeddings.md +157 -0
  81. data/resources/deepdive/fundamentals/repl.md +87 -0
  82. data/resources/deepdive/fundamentals/schema.md +61 -0
  83. data/resources/deepdive/fundamentals/skills.md +106 -0
  84. data/resources/deepdive/fundamentals/stream.md +110 -0
  85. data/resources/deepdive/fundamentals/tools.md +265 -0
  86. data/resources/deepdive/protocols/a2a.md +106 -0
  87. data/resources/deepdive/protocols/mcp.md +111 -0
  88. data/resources/deepdive.md +7 -1
  89. metadata +36 -3
  90. data/lib/llm/loop_guard.rb +0 -107
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Guard
4
+ ##
5
+ # {LLM::Guard::Loop LLM::Guard::Loop} is the built-in loop-detection
6
+ # guard. It detects when a context is repeating the same tool-call
7
+ # pattern instead of making progress, and returns an in-band
8
+ # {LLM::Function::Return LLM::Function::Return} so {LLM::Context
9
+ # LLM::Context} can block the pending tool work.
10
+ class Loop < self
11
+ ##
12
+ # The default number of repeated tool-call patterns required before
13
+ # the guard intervenes.
14
+ # @return [Integer]
15
+ DEFAULT_THRESHOLD = 3
16
+
17
+ ##
18
+ # Checks the current context for repeated tool-call patterns.
19
+ #
20
+ # This method inspects assistant tool calls only. It reduces each call
21
+ # to a `[tool_name, arguments]` signature and checks whether the tail
22
+ # of the sequence is repeating.
23
+ #
24
+ # @param [LLM::Function] function
25
+ # The pending function being decided about. Only its id and name are
26
+ # used to build the return.
27
+ # @param [Integer] threshold
28
+ # How many repeated tool-call patterns must appear at the tail of the
29
+ # sequence before the guard returns a warning.
30
+ # @param [Hash] opts
31
+ # Additional per-call options (ignored).
32
+ # @return [LLM::Function::Return, nil]
33
+ # Returns a tool return that blocks this function's pending call, or
34
+ # nil when execution should continue.
35
+ def call(function:, threshold: DEFAULT_THRESHOLD, **opts)
36
+ repetitions = detect(messages.to_a, threshold)
37
+ return unless repetitions
38
+ function.return(error: true, type: "guard_error", message: warning(repetitions))
39
+ end
40
+
41
+ private
42
+
43
+ def detect(messages, threshold)
44
+ signatures = extract_signatures(messages)
45
+ return if signatures.size < threshold
46
+ check_repeating_pattern(signatures, threshold)
47
+ end
48
+
49
+ def warning(repetitions)
50
+ <<~MSG
51
+ SYSTEM NOTICE: Repeated tool-call pattern detected - the same pattern has repeated #{repetitions} times.
52
+ You are stuck in a loop and not making progress. Stop and try a fundamentally different approach:
53
+ - Re-read the relevant context before retrying
54
+ - Try a different tool or strategy
55
+ - Break the problem into smaller steps
56
+ - If a tool keeps failing, investigate why before retrying
57
+ MSG
58
+ end
59
+
60
+ def extract_signatures(messages)
61
+ messages
62
+ .select(&:assistant?)
63
+ .flat_map { |message| message.functions.map { [_1.name.to_s, _1.arguments.to_h] } }
64
+ end
65
+
66
+ def check_repeating_pattern(sequence, threshold)
67
+ max_pattern_len = sequence.size / threshold
68
+ (1..max_pattern_len).each do |pattern_len|
69
+ count = count_tail_repetitions(sequence, pattern_len)
70
+ return count if count >= threshold
71
+ end
72
+ nil
73
+ end
74
+
75
+ def count_tail_repetitions(sequence, length)
76
+ return 0 if sequence.size < length
77
+ pattern = sequence.last(length)
78
+ count = 1
79
+ pos = sequence.size - length
80
+ while pos >= length
81
+ candidate = sequence[(pos - length)...pos]
82
+ break unless candidate == pattern
83
+ count += 1
84
+ pos -= length
85
+ end
86
+ count
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Guard
4
+ ##
5
+ # {LLM::Guard::Null LLM::Guard::Null} is a no-op guard. It never blocks
6
+ # pending tool work and is the default guard used by
7
+ # {LLM::Context LLM::Context}.
8
+ class Null < self
9
+ ##
10
+ # @param [LLM::Function] function
11
+ # The pending function (ignored)
12
+ # @param [Hash] opts
13
+ # Per-call options (ignored)
14
+ # @return [nil]
15
+ def call(function:, **opts)
16
+ nil
17
+ end
18
+ end
19
+ end
data/lib/llm/guard.rb ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # {LLM::Guard LLM::Guard} is the superclass for context-level
6
+ # supervisors in llm.rb.
7
+ #
8
+ # A guard is bound to a context and decides whether a tool call should
9
+ # be blocked before it runs. Each subclass implements {#call} and returns
10
+ # an {LLM::Function::Return LLM::Function::Return} that closes the
11
+ # pending tool call, or `nil` when execution should continue. The guard
12
+ # is stamped onto the functions the context binds, so it runs whenever a
13
+ # task is spawned — including tool calls queued from a stream. The
14
+ # built-in implementation is {LLM::Guard::Loop LLM::Guard::Loop}, which
15
+ # detects repeated tool-call patterns. {LLM::Guard::Null LLM::Guard::Null}
16
+ # is a no-op and the default.
17
+ #
18
+ # {LLM::Agent LLM::Agent} enables {LLM::Guard::Loop LLM::Guard::Loop}
19
+ # by default through its wrapped context.
20
+ class Guard
21
+ require_relative "guard/null"
22
+ require_relative "guard/loop"
23
+
24
+ ##
25
+ # @return [LLM::Context]
26
+ attr_reader :ctx
27
+
28
+ ##
29
+ # @param ctx [LLM::Context, LLM::Agent]
30
+ # @return [LLM::Guard]
31
+ def initialize(ctx)
32
+ @ctx = LLM::Agent === ctx ? ctx.instance_variable_get(:@ctx) : ctx
33
+ end
34
+
35
+ ##
36
+ # @abstract
37
+ # @param [LLM::Function] function
38
+ # The pending function the guard is deciding about.
39
+ # @param opts [Hash] Per-call options
40
+ # @return [LLM::Function::Return, nil]
41
+ # A tool return that closes this function's pending call when it
42
+ # should be blocked, or nil when execution should continue.
43
+ def call(function:, **opts)
44
+ raise NotImplementedError
45
+ end
46
+
47
+ private
48
+
49
+ ##
50
+ # @return [LLM::Stream]
51
+ def stream
52
+ @ctx.params[:stream]
53
+ end
54
+
55
+ ##
56
+ # @return [LLM::Buffer]
57
+ def messages
58
+ @ctx.messages
59
+ end
60
+ end
61
+ end
data/lib/llm/provider.rb CHANGED
@@ -40,6 +40,42 @@ class LLM::Provider
40
40
  @monitor = Monitor.new
41
41
  end
42
42
 
43
+ ##
44
+ # Builds the outgoing message array for a turn. Normalizes the
45
+ # prompt into one or more {LLM::Message} objects and prepends
46
+ # the existing history.
47
+ #
48
+ # The method is idempotent. If the prompt is already an
49
+ # {LLM::Message} or an array of {LLM::Message}s (ie it was built
50
+ # by a previous call and possibly transformed), it is returned
51
+ # as-is without rebuilding.
52
+ #
53
+ # @param [String, Array, LLM::Message, LLM::Prompt] prompt
54
+ # @param [Hash] params
55
+ # Turn params. The history is taken from `params[:messages]`.
56
+ # @param [Symbol] role
57
+ # The role to assign to a raw prompt
58
+ # @param [Symbol] key
59
+ # The params key that holds the history (`:messages` for chat
60
+ # completions, `:input` for the responses API).
61
+ # @return [Array<LLM::Message>]
62
+ def build_messages(prompt, params, role, key: :messages)
63
+ case prompt
64
+ when LLM::Message
65
+ [prompt]
66
+ when Array
67
+ if prompt.all? { LLM::Message === _1 }
68
+ prompt
69
+ else
70
+ [*(params.delete(key) || []), LLM::Message.new(role, prompt)]
71
+ end
72
+ when LLM::Prompt
73
+ [*(params.delete(key) || []), *prompt.to_a]
74
+ else
75
+ [*(params.delete(key) || []), LLM::Message.new(role, prompt)]
76
+ end
77
+ end
78
+
43
79
  ##
44
80
  # Returns an inspection of the provider object
45
81
  # @return [String]
@@ -102,6 +102,7 @@ class LLM::Anthropic
102
102
  fn.arguments = LLM::Anthropic.parse_tool_input(tool["input"])
103
103
  fn.tracer = @stream.extra[:tracer]
104
104
  fn.model = @stream.extra[:model]
105
+ fn.guard = @stream.extra[:guard]
105
106
  end
106
107
  [fn, (registered ? nil : fn.unavailable)]
107
108
  end
@@ -167,7 +167,7 @@ module LLM
167
167
  end
168
168
 
169
169
  def build_complete_request(prompt, params, role)
170
- messages = build_complete_messages(prompt, params, role)
170
+ messages = build_messages(prompt, params, role)
171
171
  payload = adapt(messages)
172
172
  body = LLM.json.dump(payload.merge!(params))
173
173
  req = LLM::Transport::Request.post("/v1/messages", headers)
@@ -175,12 +175,5 @@ module LLM
175
175
  req
176
176
  end
177
177
 
178
- def build_complete_messages(prompt, params, role)
179
- if LLM::Prompt === prompt
180
- [*(params.delete(:messages) || []), *prompt.to_a]
181
- else
182
- [*(params.delete(:messages) || []), Message.new(role, prompt)]
183
- end
184
- end
185
178
  end
186
179
  end
@@ -181,6 +181,7 @@ class LLM::Bedrock
181
181
  f.arguments = payload["input"] || {}
182
182
  f.tracer = @stream.extra[:tracer]
183
183
  f.model = @stream.extra[:model]
184
+ f.guard = @stream.extra[:guard]
184
185
  end
185
186
  [fn, registered ? nil : fn.unavailable]
186
187
  end
@@ -210,7 +210,7 @@ module LLM
210
210
  end
211
211
 
212
212
  def build_complete_request(prompt, params, role, stream: nil)
213
- messages = build_complete_messages(prompt, params, role)
213
+ messages = build_messages(prompt, params, role)
214
214
  model_id = params.delete(:model) || default_model
215
215
  payload = build_converse_payload(messages, params)
216
216
  body = LLM.json.dump(payload)
@@ -221,13 +221,6 @@ module LLM
221
221
  [req, messages, body]
222
222
  end
223
223
 
224
- def build_complete_messages(prompt, params, role)
225
- if LLM::Prompt === prompt
226
- [*(params.delete(:messages) || []), *prompt]
227
- else
228
- [*(params.delete(:messages) || []), Message.new(role, prompt)]
229
- end
230
- end
231
224
 
232
225
  def build_converse_payload(messages, params)
233
226
  adapted = adapt(messages)
@@ -150,6 +150,7 @@ class LLM::Google
150
150
  fn.arguments = call["args"]
151
151
  fn.tracer = @stream.extra[:tracer]
152
152
  fn.model = @stream.extra[:model]
153
+ fn.guard = @stream.extra[:guard]
153
154
  end
154
155
  [fn, (registered ? nil : fn.unavailable)]
155
156
  end
@@ -217,18 +217,11 @@ module LLM
217
217
  model.respond_to?(:id) ? model.id : model
218
218
  path = ["/v1beta/models/#{model}", action].join(":")
219
219
  req = LLM::Transport::Request.post(path, headers)
220
- messages = build_complete_messages(prompt, params, role)
220
+ messages = build_messages(prompt, params, role)
221
221
  body = LLM.json.dump({contents: adapt(messages)}.merge!(params))
222
222
  transport.set_body_stream(req, StringIO.new(body))
223
223
  req
224
224
  end
225
225
 
226
- def build_complete_messages(prompt, params, role)
227
- if LLM::Prompt === prompt
228
- [*(params.delete(:messages) || []), *prompt.to_a]
229
- else
230
- [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
231
- end
232
- end
233
226
  end
234
227
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai" unless defined?(LLM::OpenAI)
4
+
5
+ module LLM
6
+ ##
7
+ # The Moonshot class implements a provider for
8
+ # [Moonshot AI](https://platform.moonshot.ai) through its
9
+ # OpenAI-compatible Kimi API.
10
+ #
11
+ # @example
12
+ # #!/usr/bin/env ruby
13
+ # require "llm"
14
+ #
15
+ # llm = LLM.moonshot(key: ENV["KEY"])
16
+ # ctx = LLM::Context.new(llm)
17
+ # ctx.talk "Hello"
18
+ class Moonshot < OpenAI
19
+ HOST = "api.moonshot.ai"
20
+ BASE_PATH = "/v1"
21
+
22
+ ##
23
+ # @param key (see LLM::Provider#initialize)
24
+ # @param host (see LLM::Provider#initialize)
25
+ # @param base_path (see LLM::Provider#initialize)
26
+ # @return [LLM::Moonshot]
27
+ def initialize(host: HOST, base_path: BASE_PATH, **)
28
+ super
29
+ end
30
+
31
+ ##
32
+ # @return [Symbol]
33
+ # Returns the provider's name
34
+ def name
35
+ :moonshot
36
+ end
37
+
38
+ ##
39
+ # @raise [NotImplementedError]
40
+ def images
41
+ raise NotImplementedError
42
+ end
43
+
44
+ ##
45
+ # @raise [NotImplementedError]
46
+ def audio
47
+ raise NotImplementedError
48
+ end
49
+
50
+ ##
51
+ # @raise [NotImplementedError]
52
+ def moderations
53
+ raise NotImplementedError
54
+ end
55
+
56
+ ##
57
+ # @raise [NotImplementedError]
58
+ def responses
59
+ raise NotImplementedError
60
+ end
61
+
62
+ ##
63
+ # @raise [NotImplementedError]
64
+ def vector_stores
65
+ raise NotImplementedError
66
+ end
67
+
68
+ ##
69
+ # Returns the default model for chat completions
70
+ # @see https://platform.moonshot.ai/docs/api/models-overview Kimi models
71
+ # @return [String]
72
+ def default_model
73
+ "kimi-k3"
74
+ end
75
+ end
76
+ end
@@ -137,19 +137,12 @@ module LLM
137
137
  end
138
138
 
139
139
  def build_complete_request(prompt, params, role)
140
- messages = build_complete_messages(prompt, params, role)
140
+ messages = build_messages(prompt, params, role)
141
141
  body = LLM.json.dump({messages: [adapt(messages)].flatten}.merge!(params))
142
142
  req = LLM::Transport::Request.post("/api/chat", headers)
143
143
  transport.set_body_stream(req, StringIO.new(body))
144
144
  req
145
145
  end
146
146
 
147
- def build_complete_messages(prompt, params, role)
148
- if LLM::Prompt === prompt
149
- [*(params.delete(:messages) || []), *prompt.to_a]
150
- else
151
- [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
152
- end
153
- end
154
147
  end
155
148
  end
@@ -265,6 +265,7 @@ class LLM::OpenAI
265
265
  fn.arguments = arguments
266
266
  fn.tracer = @stream.extra[:tracer]
267
267
  fn.model = @stream.extra[:model]
268
+ fn.guard = @stream.extra[:guard]
268
269
  end
269
270
  [fn, (registered ? nil : fn.unavailable)]
270
271
  end
@@ -44,8 +44,13 @@ class LLM::OpenAI
44
44
  adapt_tools(tools)
45
45
  ].inject({}, &:merge!).compact
46
46
  req = LLM::Transport::Request.post(path("/responses"), headers)
47
- messages = build_complete_messages(prompt, params, role)
47
+ messages = @provider.build_messages(prompt, params, role, key: :input)
48
48
  @provider.tracer.set_request_metadata(user_input: extract_user_input(messages, fallback: prompt))
49
+ ##
50
+ # The input is derived from `prompt`/`messages` above; a
51
+ # stale `:input` passed in params (eg by LLM::Context) must
52
+ # not overwrite it in the request body.
53
+ params.delete(:input)
49
54
  body = LLM.json.dump({input: [adapt(messages, mode: :response)].flatten}.merge!(params))
50
55
  transport.set_body_stream(req, StringIO.new(body))
51
56
  res, span, tracer = execute(request: req, stream:, stream_parser:, operation: "chat", model: params[:model])
@@ -102,13 +107,6 @@ class LLM::OpenAI
102
107
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
103
108
  end
104
109
 
105
- def build_complete_messages(prompt, params, role)
106
- if LLM::Prompt === prompt
107
- [*(params.delete(:input) || []), *prompt]
108
- else
109
- [*(params.delete(:input) || []), LLM::Message.new(role, prompt)]
110
- end
111
- end
112
110
 
113
111
  def adapt_schema(params)
114
112
  return {} unless params && params[:schema]
@@ -180,6 +180,7 @@ class LLM::OpenAI
180
180
  fn.arguments = arguments
181
181
  fn.tracer = @stream.extra[:tracer]
182
182
  fn.model = @stream.extra[:model]
183
+ fn.guard = @stream.extra[:guard]
183
184
  end
184
185
  [fn, (registered ? nil : fn.unavailable)]
185
186
  end
@@ -146,10 +146,10 @@ module LLM
146
146
 
147
147
  ##
148
148
  # Returns the default model for chat completions
149
- # @see https://platform.openai.com/docs/models/gpt-5.4-mini gpt-5.4-mini
149
+ # @see https://platform.openai.com/docs/models/gpt-5.6-luna gpt-5.6-luna
150
150
  # @return [String]
151
151
  def default_model
152
- "gpt-5.4-mini"
152
+ "gpt-5.6-luna"
153
153
  end
154
154
 
155
155
  ##
@@ -230,20 +230,13 @@ module LLM
230
230
  end
231
231
 
232
232
  def build_complete_request(prompt, params, role)
233
- messages = build_complete_messages(prompt, params, role)
233
+ messages = build_messages(prompt, params, role)
234
234
  body = LLM.json.dump({messages: adapt(messages, mode: :complete).flatten}.merge!(params))
235
235
  req = LLM::Transport::Request.post(completions_path, headers)
236
236
  transport.set_body_stream(req, StringIO.new(body))
237
237
  [req, messages]
238
238
  end
239
239
 
240
- def build_complete_messages(prompt, params, role)
241
- if LLM::Prompt === prompt
242
- [*(params.delete(:messages) || []), *prompt]
243
- else
244
- [*(params.delete(:messages) || []), Message.new(role, prompt)]
245
- end
246
- end
247
240
 
248
241
  def extract_user_input(messages, fallback:)
249
242
  message = messages.reverse.find(&:user?) || messages.last
data/lib/llm/repl/bar.rb CHANGED
@@ -17,7 +17,8 @@ class LLM::Repl
17
17
  FREE = " "
18
18
 
19
19
  ##
20
- # @param [Integer] used
20
+ # @param [Integer, nil] used
21
+ # The used context, or nil when it is unknown (eg after compaction).
21
22
  # @param [Integer] total
22
23
  # @param [Integer] width
23
24
  # @return [LLM::Repl::Bar]
@@ -36,11 +37,11 @@ class LLM::Repl
36
37
  private
37
38
 
38
39
  ##
39
- # @param [Integer] used
40
+ # @param [Integer, nil] used
40
41
  # @param [Integer] total
41
42
  # @return [[String, Integer]]
42
43
  def remainder(used, total)
43
- return ["???", width] if total <= 0
44
+ return ["???", width] if used.nil? || total <= 0
44
45
  diff = total - used
45
46
  return ["0%", 0] if diff <= 0
46
47
  remaining = (diff.fdiv(total) * 100).round(2)
@@ -20,7 +20,8 @@ class LLM::Repl
20
20
  # An instance of {LLM::Repl LLM::Repl}.
21
21
  # @return [LLM::Repl::Buffer]
22
22
  def initialize(repl)
23
- @repl = repl
23
+ @repl = repl
24
+ @window = repl.window
24
25
  @rows = [[]]
25
26
  @cursor = nil
26
27
  @snapshot = nil
@@ -46,12 +47,12 @@ class LLM::Repl
46
47
  # @param [Symbol] method
47
48
  # @return [void]
48
49
  def write_message(user, content, method: :append)
49
- chunks = [Node.new("#{user}: ", Curses::A_BOLD)]
50
+ chunks = [Node.new("#{user}:\n", Curses::A_BOLD | Color.blue)]
50
51
  case content
51
52
  when Array then chunks.concat(content)
52
53
  else chunks.push(Node.new(content))
53
54
  end
54
- chunks.push(Node.new("\n"))
55
+ chunks.push(Node.new("\n\n"))
55
56
  write(chunks, method:)
56
57
  end
57
58
 
@@ -90,6 +91,15 @@ class LLM::Repl
90
91
  @offset = 0
91
92
  end
92
93
 
94
+ ##
95
+ # The width of the centered content area -
96
+ # with 20% on each side. The rest makes up
97
+ # the width of the buffer.
98
+ # @return [Integer]
99
+ def width
100
+ (Curses.cols * 0.6).floor
101
+ end
102
+
93
103
  ##
94
104
  # @param [Integer] height
95
105
  # @return [Array<String>]
@@ -103,8 +113,8 @@ class LLM::Repl
103
113
  private
104
114
 
105
115
  ##
106
- # @return [LLM::Repl]
107
- attr_reader :repl
116
+ # @api private
117
+ attr_reader :repl, :window
108
118
 
109
119
  ##
110
120
  # Appends a new row
@@ -126,20 +136,37 @@ class LLM::Repl
126
136
  end
127
137
 
128
138
  ##
129
- # Given a chunk this method wraps text at
130
- # around 80 columns: a new row starts when
131
- # the current character is " " and the sum
132
- # of all characters in that row is greater
133
- # than 80 columns.
139
+ # Given a chunk this method wraps text to the buffer's
140
+ # width. The text is split into words; a word that does
141
+ # not fit on the current row moves to the next. Only a
142
+ # single word longer than the whole width is hard-broken,
143
+ # so text is never clipped by the window.
134
144
  def wrap(chunk, rows)
135
145
  attrs = chunk[:attrs]
136
- chunk[:text].to_s.each_char do |char|
137
- if char == "\n"
138
- rows << []
139
- elsif char == " " and sum(rows.last) >= repl.width
146
+ chunk[:text].to_s.scan(/\n|[^\s]+|\s+/) do |token|
147
+ if token == "\n"
140
148
  rows << []
149
+ elsif token.match?(/\s/)
150
+ if sum(rows.last) + token.length <= width
151
+ rows.last << Node.new(token, attrs)
152
+ end
153
+ elsif sum(rows.last) + token.length > width
154
+ if sum(rows.last) == 0
155
+ ##
156
+ # A single word wider than the row: break it
157
+ # every width characters.
158
+ token.scan(/.{1,#{width}}/).each do |piece|
159
+ rows.last << Node.new(piece, attrs)
160
+ if sum(rows.last) >= width
161
+ rows << []
162
+ end
163
+ end
164
+ else
165
+ rows << []
166
+ rows.last << Node.new(token, attrs)
167
+ end
141
168
  else
142
- rows.last << Node.new(char, attrs)
169
+ rows.last << Node.new(token, attrs)
143
170
  end
144
171
  end
145
172
  end