rcas 0.2.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 (117) hide show
  1. checksums.yaml +7 -0
  2. data/CITATION.cff +17 -0
  3. data/DESIGN.md +783 -0
  4. data/LICENSE +21 -0
  5. data/MANUAL.md +6265 -0
  6. data/README.md +267 -0
  7. data/bin/rcas +9 -0
  8. data/bin/rcas-app +9 -0
  9. data/bin/rcas-chat +9 -0
  10. data/lib/rcas/algebraic.rb +481 -0
  11. data/lib/rcas/analysis.rb +966 -0
  12. data/lib/rcas/app/launcher.rb +203 -0
  13. data/lib/rcas/app/public/app.css +402 -0
  14. data/lib/rcas/app/public/app.js +449 -0
  15. data/lib/rcas/app/public/index.html +46 -0
  16. data/lib/rcas/app/server.rb +220 -0
  17. data/lib/rcas/app/window.rb +94 -0
  18. data/lib/rcas/app/worksheet.rb +290 -0
  19. data/lib/rcas/app.rb +168 -0
  20. data/lib/rcas/background.rb +758 -0
  21. data/lib/rcas/chat/assistant.rb +199 -0
  22. data/lib/rcas/chat/picker.rb +164 -0
  23. data/lib/rcas/chat/repl.rb +583 -0
  24. data/lib/rcas/chat/session.rb +137 -0
  25. data/lib/rcas/chat/settings.rb +71 -0
  26. data/lib/rcas/chat/style.rb +30 -0
  27. data/lib/rcas/chat/tool.rb +53 -0
  28. data/lib/rcas/chat/ui.rb +316 -0
  29. data/lib/rcas/chat/usage.rb +62 -0
  30. data/lib/rcas/chat/workspace.rb +132 -0
  31. data/lib/rcas/chat.rb +54 -0
  32. data/lib/rcas/coefficients.rb +170 -0
  33. data/lib/rcas/combinatorics.rb +274 -0
  34. data/lib/rcas/complex_parts.rb +160 -0
  35. data/lib/rcas/constants.rb +129 -0
  36. data/lib/rcas/core_ext.rb +35 -0
  37. data/lib/rcas/decide.rb +501 -0
  38. data/lib/rcas/decompositions.rb +241 -0
  39. data/lib/rcas/differentiate.rb +144 -0
  40. data/lib/rcas/discussion.rb +558 -0
  41. data/lib/rcas/distributions.rb +980 -0
  42. data/lib/rcas/dixon.rb +95 -0
  43. data/lib/rcas/docs.rb +321 -0
  44. data/lib/rcas/domains.rb +728 -0
  45. data/lib/rcas/expand.rb +174 -0
  46. data/lib/rcas/expression.rb +613 -0
  47. data/lib/rcas/factor.rb +605 -0
  48. data/lib/rcas/finite_field.rb +577 -0
  49. data/lib/rcas/fourier.rb +118 -0
  50. data/lib/rcas/fps.rb +678 -0
  51. data/lib/rcas/fraction.rb +126 -0
  52. data/lib/rcas/functions.rb +1136 -0
  53. data/lib/rcas/gcd.rb +112 -0
  54. data/lib/rcas/geometry.rb +266 -0
  55. data/lib/rcas/groebner.rb +162 -0
  56. data/lib/rcas/hold.rb +277 -0
  57. data/lib/rcas/hypothesis.rb +364 -0
  58. data/lib/rcas/inequalities.rb +689 -0
  59. data/lib/rcas/integral_functions.rb +260 -0
  60. data/lib/rcas/integrate.rb +1589 -0
  61. data/lib/rcas/integrate_substitutions.rb +434 -0
  62. data/lib/rcas/interpolate.rb +40 -0
  63. data/lib/rcas/irb.rb +146 -0
  64. data/lib/rcas/laplace.rb +159 -0
  65. data/lib/rcas/latex.rb +556 -0
  66. data/lib/rcas/lattice.rb +172 -0
  67. data/lib/rcas/linear_algebra.rb +117 -0
  68. data/lib/rcas/linear_program.rb +416 -0
  69. data/lib/rcas/lint.rb +79 -0
  70. data/lib/rcas/matrix.rb +531 -0
  71. data/lib/rcas/matrix_multiply.rb +202 -0
  72. data/lib/rcas/multimodular.rb +286 -0
  73. data/lib/rcas/named_polynomials.rb +274 -0
  74. data/lib/rcas/number_theory.rb +443 -0
  75. data/lib/rcas/numerics.rb +825 -0
  76. data/lib/rcas/ode.rb +488 -0
  77. data/lib/rcas/openmath/objects.rb +364 -0
  78. data/lib/rcas/openmath/phrasebook.rb +551 -0
  79. data/lib/rcas/openmath/popcorn.rb +518 -0
  80. data/lib/rcas/openmath/xml.rb +309 -0
  81. data/lib/rcas/openmath.rb +49 -0
  82. data/lib/rcas/petkovsek.rb +165 -0
  83. data/lib/rcas/piecewise.rb +488 -0
  84. data/lib/rcas/plot.rb +763 -0
  85. data/lib/rcas/plot3d.rb +419 -0
  86. data/lib/rcas/poly_matrix.rb +318 -0
  87. data/lib/rcas/poly_recurrence.rb +117 -0
  88. data/lib/rcas/polynomial.rb +466 -0
  89. data/lib/rcas/precision.rb +925 -0
  90. data/lib/rcas/printer.rb +150 -0
  91. data/lib/rcas/product.rb +155 -0
  92. data/lib/rcas/q_difference.rb +296 -0
  93. data/lib/rcas/q_functions.rb +158 -0
  94. data/lib/rcas/q_summation.rb +308 -0
  95. data/lib/rcas/q_zeilberger.rb +199 -0
  96. data/lib/rcas/random.rb +506 -0
  97. data/lib/rcas/rational_function.rb +186 -0
  98. data/lib/rcas/recurrence.rb +323 -0
  99. data/lib/rcas/render.rb +431 -0
  100. data/lib/rcas/results.rb +192 -0
  101. data/lib/rcas/scalar.rb +219 -0
  102. data/lib/rcas/series.rb +726 -0
  103. data/lib/rcas/simplify.rb +649 -0
  104. data/lib/rcas/solve.rb +2002 -0
  105. data/lib/rcas/special.rb +163 -0
  106. data/lib/rcas/statistics.rb +175 -0
  107. data/lib/rcas/steps.rb +835 -0
  108. data/lib/rcas/summation.rb +532 -0
  109. data/lib/rcas/trig.rb +264 -0
  110. data/lib/rcas/van_hoeij.rb +241 -0
  111. data/lib/rcas/vector.rb +175 -0
  112. data/lib/rcas/vector_calculus.rb +411 -0
  113. data/lib/rcas/version.rb +5 -0
  114. data/lib/rcas/zeilberger.rb +358 -0
  115. data/lib/rcas.rb +91 -0
  116. data/package.json +8 -0
  117. metadata +206 -0
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "securerandom"
6
+ require "time"
7
+ require "date"
8
+
9
+ module RCAS
10
+ module Chat
11
+ # A saved session: the transcript, the conversation with Claude and the
12
+ # settings, as JSON under ~/.rcas/sessions. Variables are not stored;
13
+ # they are rebuilt by replaying the transcript's Ruby when resuming.
14
+ class Session
15
+ DIR = ENV.fetch("RCAS_SESSION_DIR", File.join(HOME, "sessions"))
16
+
17
+ attr_reader :id, :created_at
18
+ attr_accessor :title, :model, :mode, :transcript, :messages, :usage, :updated_at, :cwd
19
+
20
+ def self.dir = DIR
21
+
22
+ def self.path(id) = File.join(dir, "#{id}.json")
23
+
24
+ def self.list
25
+ return [] unless File.directory?(dir)
26
+ Dir[File.join(dir, "*.json")].filter_map { |f| load(File.basename(f, ".json")) rescue nil }.sort_by { |s| [s.updated_at, s.id] }.reverse
27
+ end
28
+
29
+ def self.latest = list.first
30
+
31
+ # By id, by name (exact, then unique prefix, case-insensitive), by a
32
+ # unique id prefix, or by a 1-based index into the list.
33
+ def self.find(ref, among: nil)
34
+ return latest if ref.nil? || ref.to_s.empty?
35
+ return load(ref) if among.nil? && File.file?(path(ref))
36
+ sessions = among || list
37
+ ref = ref.to_s.strip
38
+ return sessions[ref.to_i - 1] if ref.match?(/\A\d{1,3}\z/) && ref.to_i.between?(1, sessions.size)
39
+ exact = sessions.select { |s| s.id == ref || s.title&.casecmp?(ref) }
40
+ return exact.first if exact.size == 1
41
+ matches = sessions.select { |s| s.id.start_with?(ref) || s.title.to_s.downcase.start_with?(ref.downcase) }
42
+ matches.size == 1 ? matches.first : nil
43
+ end
44
+
45
+ def self.load(id)
46
+ data = JSON.parse(File.read(path(id)), symbolize_names: true)
47
+ new(id: data[:id], created_at: Time.parse(data[:created_at])).tap do |s|
48
+ s.updated_at = Time.parse(data[:updated_at])
49
+ s.title = data[:title]
50
+ s.model = data[:model]
51
+ s.mode = data[:mode]&.to_sym
52
+ s.cwd = data[:cwd]
53
+ s.transcript = data[:transcript].to_a.map { |e| [e[0].to_sym, *e[1..]] }
54
+ s.messages = data[:messages].to_a
55
+ s.usage = (data[:usage] || {}).transform_keys(&:to_sym)
56
+ end
57
+ end
58
+
59
+ def initialize(id: nil, created_at: Time.now)
60
+ @id = id || "#{created_at.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.hex(2)}"
61
+ @created_at = created_at
62
+ @updated_at = created_at
63
+ @transcript = []
64
+ @messages = []
65
+ @usage = {}
66
+ @cwd = Dir.pwd
67
+ end
68
+
69
+ def path = self.class.path(id)
70
+
71
+ def save
72
+ @updated_at = Time.now
73
+ @title ||= transcript.find { |k, _| k == :input }&.[](1)&.lines&.first&.strip&.[](0, 60)
74
+ FileUtils.mkdir_p(self.class.dir)
75
+ File.write(path, JSON.pretty_generate(to_h))
76
+ self
77
+ end
78
+
79
+ def to_h
80
+ {
81
+ id: id, created_at: created_at.iso8601(6), updated_at: updated_at.iso8601(6), title: title,
82
+ model: model, mode: mode, cwd: cwd, usage: usage,
83
+ transcript: transcript,
84
+ messages: JSON.parse(JSON.generate(messages))
85
+ }
86
+ end
87
+
88
+ def delete
89
+ File.delete(path) if File.file?(path)
90
+ end
91
+
92
+ def turns = transcript.count { |k, _| k == :input }
93
+
94
+ # The first thing typed, as a one-line preview.
95
+ def first_input
96
+ transcript.find { |k, _| k == :input }&.[](1)&.lines&.first&.strip&.[](0, 80)
97
+ end
98
+
99
+ # The title, or the first input when the session was never named.
100
+ def name = title.to_s.empty? ? (first_input || "(empty)") : title
101
+
102
+ # "just now", "5m ago", "3h ago", "yesterday 14:02", "Sep 10 14:02"
103
+ def relative_time(now = Time.now)
104
+ age = now - updated_at
105
+ return "just now" if age < 60
106
+ return "#{(age / 60).floor}m ago" if age < 3600
107
+ return "#{(age / 3600).floor}h ago" if age < 86_400 && updated_at.to_date == now.to_date
108
+ return "yesterday #{updated_at.strftime('%H:%M')}" if updated_at.to_date == (now.to_date - 1)
109
+ updated_at.strftime(updated_at.year == now.year ? "%b %d %H:%M" : "%Y-%m-%d")
110
+ end
111
+
112
+ def summary
113
+ "#{id} #{relative_time.ljust(15)} #{turns.to_s.rjust(3)} inputs #{name}"
114
+ end
115
+
116
+ # Bring a workspace back to the state the transcript left it in.
117
+ def replay_into(workspace)
118
+ transcript.each do |kind, text, _|
119
+ case kind
120
+ when :input
121
+ next if text.start_with?("!", "?")
122
+ if text.start_with?("/")
123
+ case text
124
+ when %r{\A/forget\b(.*)} then RCAS.forget(*Regexp.last_match(1).split.map(&:to_sym))
125
+ when %r{\A/reset\b} then workspace = Workspace.new
126
+ end
127
+ next
128
+ end
129
+ workspace.replay(text)
130
+ when :tool then workspace.replay(text)
131
+ end
132
+ end
133
+ workspace
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module RCAS
7
+ module Chat
8
+ # ~/.rcas (RCAS_HOME): settings.json, history, sessions/
9
+ HOME = ENV.fetch("RCAS_HOME", File.join(Dir.home, ".rcas"))
10
+
11
+ # Persistent defaults in ~/.rcas/settings.json. Precedence, lowest first:
12
+ # this file, environment variables, command-line flags, /commands in a
13
+ # session. `/settings save` writes the current values back.
14
+ module Settings
15
+ FILE = File.join(HOME, "settings.json")
16
+ KEYS = %w[output backend scale theme wrap plotstyle unicode numbered model fallbacks].freeze
17
+
18
+ module_function
19
+
20
+ def load(file = FILE)
21
+ return {} unless File.file?(file)
22
+ data = JSON.parse(File.read(file))
23
+ raise Error, "settings must be a JSON object" unless data.is_a?(Hash)
24
+ data.select { |k, _| KEYS.include?(k) }
25
+ rescue JSON::ParserError, Error => e
26
+ warn "rcas: ignoring #{file}: #{e.message}"
27
+ {}
28
+ end
29
+
30
+ def save(settings, file = FILE)
31
+ FileUtils.mkdir_p(File.dirname(file))
32
+ File.write(file, JSON.pretty_generate(settings.select { |k, _| KEYS.include?(k) }) + "\n")
33
+ file
34
+ end
35
+
36
+ def reset(file = FILE)
37
+ File.delete(file) if File.file?(file)
38
+ end
39
+
40
+ # Apply the file's rendering defaults where no environment variable
41
+ # overrides them. Output mode, model and fallbacks are consumed by the
42
+ # REPL because they depend on the session.
43
+ def apply(settings)
44
+ Render.backend = settings["backend"].to_sym if settings["backend"] && !ENV["RCAS_TEX_BACKEND"]
45
+ Render.scale = settings["scale"].to_f if settings["scale"] && !ENV["RCAS_TEX_SCALE"]
46
+ Render.theme = settings["theme"].to_sym if settings["theme"] && !ENV["RCAS_TEX_THEME"]
47
+ Render.wrap = settings["wrap"].to_i if settings["wrap"] && !ENV["RCAS_TEX_WRAP"]
48
+ Plot.style = settings["plotstyle"] if settings["plotstyle"] && !ENV["RCAS_PLOT_STYLE"]
49
+ RCAS.unicode = settings["unicode"] if settings.key?("unicode") && !ENV["RCAS_UNICODE"]
50
+ RCAS.numbered = settings["numbered"] if settings.key?("numbered") && !ENV["RCAS_NUMBERED"]
51
+ settings
52
+ end
53
+
54
+ # The values in force right now, in the file's format.
55
+ def current(ui, assistant)
56
+ {
57
+ "output" => ui.mode.to_s,
58
+ "backend" => (Render.available? ? Render.selected.name.to_s : nil),
59
+ "scale" => Render.scale,
60
+ "theme" => Render.theme.to_s,
61
+ "wrap" => Render.wrap,
62
+ "plotstyle" => Plot.style.to_s,
63
+ "unicode" => RCAS.unicode?,
64
+ "numbered" => RCAS.numbered?,
65
+ "model" => (assistant.model if assistant.available?),
66
+ "fallbacks" => (assistant.fallbacks if assistant.available?)
67
+ }.compact
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ module Chat
5
+ # ANSI colours, off when not writing to a terminal or NO_COLOR is set.
6
+ module Style
7
+ CODES = { bold: 1, dim: 2, italic: 3, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37 }.freeze
8
+
9
+ class << self
10
+ attr_writer :enabled
11
+
12
+ def enabled?
13
+ return @enabled unless @enabled.nil?
14
+ $stdout.respond_to?(:tty?) && $stdout.tty? && ENV["NO_COLOR"].nil? && ENV["TERM"] != "dumb"
15
+ end
16
+
17
+ def paint(text, *styles)
18
+ return text.to_s if styles.empty? || !enabled?
19
+ "\e[#{styles.map { |s| CODES.fetch(s) }.join(';')}m#{text}\e[0m"
20
+ end
21
+
22
+ def strip(text) = text.to_s.gsub(/\e\[[\d;]*m/, "")
23
+
24
+ CODES.each_key do |name|
25
+ define_method(name) { |text| paint(text, name) }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ module Chat
5
+ # The tool Claude calls to run Ruby in the session. It subclasses the
6
+ # anthropic gem's classes, so it is loaded after the gem and only with
7
+ # it, by Chat.load_anthropic.
8
+ class EvalInput < Anthropic::BaseModel
9
+ required :code, String, doc: "Ruby code to evaluate in the user's rcas session. Variables persist between calls."
10
+ optional :show, Anthropic::Boolean, doc: "Display the result typeset in the user's terminal. Use it for the final result(s) the user asked for."
11
+ end
12
+
13
+ # Runs Ruby in the workspace on Claude's behalf and echoes the call and
14
+ # its result to the terminal, Claude-Code style.
15
+ class EvalTool < Anthropic::BaseTool
16
+ self.tool_name = "rcas_eval"
17
+ description "Evaluate Ruby code in the live rcas session and return the printed output and the result (as text and LaTeX)."
18
+ input_schema EvalInput
19
+
20
+ MAX_RESULT = 6000
21
+
22
+ attr_accessor :on_call
23
+
24
+ def initialize(workspace, ui)
25
+ @workspace = workspace
26
+ @ui = ui
27
+ end
28
+
29
+ def call(input)
30
+ code = input.code.to_s
31
+ @ui.tool_call("rcas_eval", code)
32
+ value, output = @ui.busy("computing") { @workspace.eval(code, capture: true) }
33
+ shown = "#{output}=> #{@ui.text_of(value)}"
34
+ @ui.tool_result(shown)
35
+ @ui.typeset(value, force: true) if input.show
36
+ on_call&.call(code, shown)
37
+ @ui.busy_start("thinking")
38
+ reply = shown
39
+ reply += "\nLaTeX: #{LaTeX.of(value)}" if @ui.typesettable?(value)
40
+ reply.size > MAX_RESULT ? "#{reply[0, MAX_RESULT]}\n… (truncated)" : reply
41
+ rescue StandardError, ScriptError => e
42
+ message = "#{e.class}: #{e.message.lines.first&.strip}"
43
+ hint = Usage.hint(e)
44
+ @ui.tool_result(message, error: true)
45
+ @ui.hint(hint)
46
+ message = ([message] + hint).join("\n") if hint
47
+ on_call&.call(code, message)
48
+ @ui.busy_start("thinking")
49
+ raise StandardError, message
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ module Chat
5
+ # Everything that is printed: results in the chosen output mode, the
6
+ # banner, Claude's streamed text and its tool calls.
7
+ class UI
8
+ # How results are displayed:
9
+ # :text - plain rcas text only
10
+ # :typeset - typeset picture only (text when the value has no LaTeX form)
11
+ # :both - text, then the picture
12
+ # :latex - text, then the LaTeX source
13
+ MODES = %i[text typeset both latex].freeze
14
+ # :tex was this mode's name until Sept 2026; a settings.json written
15
+ # before then, and the habit, still say it.
16
+ ALIASES = { tex: :typeset }.freeze
17
+
18
+ attr_reader :out, :mode
19
+
20
+ def initialize(out: $stdout, mode: nil)
21
+ @out = out
22
+ @mode = mode || default_mode
23
+ end
24
+
25
+ def default_mode
26
+ Render.inline?(@out) && Render.available? ? :typeset : :text
27
+ end
28
+
29
+ def mode=(value)
30
+ value = self.class.mode_for(value)
31
+ raise Error, "output mode must be one of #{MODES.join(', ')}" if value.nil?
32
+ @mode = value
33
+ end
34
+
35
+ # The mode +name+ stands for, or nil for a name that is none.
36
+ def self.mode_for(name)
37
+ return nil if name.nil? || name.to_s.empty?
38
+ name = name.to_sym
39
+ name = ALIASES.fetch(name, name)
40
+ MODES.include?(name) ? name : nil
41
+ end
42
+
43
+ def typeset? = %i[typeset both].include?(@mode)
44
+
45
+ def io = @out
46
+ def puts(text = "") = @out.puts(text)
47
+ def print(text) = @out.print(text)
48
+ def flush = @out.flush
49
+
50
+ # ---- banner -------------------------------------------------------------
51
+
52
+ # Without Claude configured, nothing here hints that it exists.
53
+ def banner(model:, backend:, assistant:, session: nil)
54
+ tagline = assistant ? "type Ruby or ask a question" : "type Ruby"
55
+ lines = [
56
+ "#{Style.paint('✻', :magenta, :bold)} #{Style.bold("rcas #{RCAS::VERSION}")} #{Style.dim("- symbols are indeterminates; #{tagline}")}",
57
+ ""
58
+ ]
59
+ lines << " #{Style.dim('model')} #{model}" if assistant
60
+ lines << " #{Style.dim('output')} #{@mode}#{typeset? ? Style.dim(" via #{backend}") : ''}"
61
+ lines << " #{Style.dim('help')} /help #{Style.dim('quit')} /exit or Ctrl-D"
62
+ lines << " #{Style.dim('session')} #{session}" if session
63
+ lines << ""
64
+ examples = ["e = (x + 1) * (1 - x)", "e.expand", "ZZ[x].(x**6 - 1).factor"]
65
+ examples << "factor x**6 - 1 over the integers" if assistant
66
+ lines << " #{Style.dim('try')} #{examples.first}"
67
+ examples.drop(1).each { |ex| lines << " #{ex}" }
68
+ box(lines)
69
+ end
70
+
71
+ def box(lines)
72
+ width = [lines.map { |l| visible_width(l) }.max + 2, columns - 2].min
73
+ width = 20 if width < 20
74
+ puts Style.dim("╭#{'─' * width}╮")
75
+ lines.each do |l|
76
+ l = truncate(l, width - 2)
77
+ pad = [width - visible_width(l) - 1, 0].max
78
+ puts "#{Style.dim('│')} #{l}#{' ' * pad}#{Style.dim('│')}"
79
+ end
80
+ puts Style.dim("╰#{'─' * width}╯")
81
+ end
82
+
83
+ # Cut a (possibly coloured) line to +max+ visible characters.
84
+ def truncate(line, max)
85
+ return line if visible_width(line) <= max
86
+ plain = Style.strip(line)
87
+ "#{plain[0, [max - 1, 0].max]}…"
88
+ end
89
+
90
+ def visible_width(text) = Style.strip(text).size
91
+
92
+ def columns
93
+ require "io/console"
94
+ width = @out.respond_to?(:winsize) && @out.tty? ? @out.winsize[1] : 0
95
+ width = ENV.fetch("COLUMNS", "0").to_i if width.to_i <= 0
96
+ width.to_i.positive? ? width.to_i : 100
97
+ rescue StandardError
98
+ 100
99
+ end
100
+
101
+ # ---- results ------------------------------------------------------------
102
+
103
+ # A plot is braille art unless /plotstyle image asks for a picture.
104
+ def plot_picture(value, force: false)
105
+ return false unless force || Plot.image?
106
+ return false unless value.picture?(@out)
107
+ print " "
108
+ value.picture(io: @out)
109
+ end
110
+
111
+ def result(value)
112
+ Results.record(value) # `Out[3]` reaches the result of the session's third line
113
+ return if value.is_a?(Plot) && plot_picture(value)
114
+ show_text = @mode != :typeset || !typesettable?(value) || !Render.inline?(@out)
115
+ if show_text
116
+ lines = text_of(value).lines.map(&:chomp)
117
+ mark = Results.mark
118
+ indent = " " * (mark ? mark.length + 1 : 0)
119
+ puts "#{mark ? "#{Style.dim(mark)} " : ''}#{Style.green(lines.first.to_s)}"
120
+ lines.drop(1).each { |l| puts "#{indent}#{Style.green(l)}" }
121
+ end
122
+ case @mode
123
+ when :typeset, :both then typeset(value)
124
+ when :latex then puts " #{Style.dim(LaTeX.of(value))}" if typesettable?(value)
125
+ end
126
+ end
127
+
128
+ def text_of(value)
129
+ value.respond_to?(:to_latex) && !value.is_a?(String) ? value.to_s : value.inspect
130
+ end
131
+
132
+ # Inline picture when possible; otherwise nothing (the text is there).
133
+ def typeset(value, force: false)
134
+ return unless typesettable?(value)
135
+ return puts(" #{LaTeX.of(value)}") unless Render.inline?(@out) && Render.available?
136
+ return unless force || typeset?
137
+ print " "
138
+ Render.show(value, io: @out)
139
+ rescue Render::Error => e
140
+ info("(typesetting failed: #{e.message.lines.first&.strip})")
141
+ end
142
+
143
+ def typesettable?(value)
144
+ case value
145
+ when Expression, Polynomial, Factorization, Domain, Vector, Matrix, VectorSpace, MatrixSpace, Symbol, Numeric then true
146
+ when ->(v) { defined?(Equation) && v.is_a?(Equation) } then true
147
+ when ->(v) { v.respond_to?(:to_latex) && !v.is_a?(String) } then true # inequalities, sets, field elements, ...
148
+ when Array then !value.empty? && value.all? { |v| typesettable?(v) }
149
+ when Hash then !value.empty? && value.values.all? { |v| v.is_a?(Domain) }
150
+ else false
151
+ end
152
+ end
153
+
154
+ def error(message) = puts(Style.red(" #{message}"))
155
+ def info(message) = puts(Style.dim(" #{message}"))
156
+ def warning(message) = puts(Style.yellow(" warning: #{message}"))
157
+
158
+ # A usage hint under an error: the first line in cyan, the rest dimmed.
159
+ def hint(lines)
160
+ return if lines.nil? || lines.empty?
161
+ first, *rest = lines
162
+ puts " #{Style.cyan(first)}"
163
+ rest.each { |l| puts " #{Style.dim(l)}" }
164
+ end
165
+
166
+ # ---- waiting ------------------------------------------------------------
167
+
168
+ def tty? = @out.respond_to?(:tty?) && @out.tty?
169
+
170
+ # A horizontal rule across the terminal.
171
+ def rule = Style.dim("─" * [columns, 200].min)
172
+
173
+ # Animate +label+ on the current line while the block runs; the line is
174
+ # cleared before anything else is printed. Returns the block's value.
175
+ def busy(label)
176
+ busy_start(label)
177
+ yield
178
+ ensure
179
+ busy_stop
180
+ end
181
+
182
+ def busy_start(label)
183
+ busy_stop
184
+ return unless tty?
185
+ @spinner = Spinner.new(@out, label).start
186
+ end
187
+
188
+ def busy_stop
189
+ @spinner&.stop
190
+ @spinner = nil
191
+ end
192
+
193
+ # Seconds a computation took, mentioned only when it was noticeable.
194
+ # At the right edge of the terminal, where it is out of the way of the
195
+ # result; piped output has no edge to hang it on, so there it is
196
+ # indented like any other aside.
197
+ def took(seconds)
198
+ return if seconds < 1.5
199
+ text = "(#{seconds.round(1)}s)"
200
+ return info(text) unless tty?
201
+ puts Style.dim(text.rjust([columns - 1, text.size + 2].max))
202
+ end
203
+
204
+ # A dimmed replay of earlier turns when a session is resumed.
205
+ def history(transcript, limit: 12)
206
+ entries = transcript.last(limit)
207
+ info("… #{transcript.size - entries.size} earlier entries") if transcript.size > entries.size
208
+ entries.each do |kind, a, b|
209
+ case kind
210
+ when :input then puts "#{Style.dim('❯')} #{Style.dim(a)}"
211
+ when :result then puts " #{Style.dim("=> #{a.lines.first&.chomp}#{a.lines.size > 1 ? ' …' : ''}")}"
212
+ when :error then puts " #{Style.dim(a.lines.first&.chomp)}"
213
+ when :assistant then puts "#{Style.dim('⏺')} #{Style.dim(a.to_s.lines.first(3).join.chomp)}#{a.to_s.lines.size > 3 ? Style.dim(' …') : ''}"
214
+ when :tool then puts "#{Style.dim('⏺')} #{Style.dim("rcas_eval(#{a.lines.first&.chomp})")}"
215
+ when :shell then puts "#{Style.dim('!')} #{Style.dim(a)}"
216
+ end
217
+ end
218
+ puts
219
+ end
220
+
221
+ # ---- assistant turn -----------------------------------------------------
222
+
223
+ def assistant_start
224
+ @in_text = false
225
+ end
226
+
227
+ def assistant_text(delta)
228
+ busy_stop
229
+ unless @in_text
230
+ print "#{Style.paint('⏺', :white, :bold)} "
231
+ @in_text = true
232
+ end
233
+ print delta.gsub("\n", "\n ")
234
+ flush
235
+ end
236
+
237
+ def assistant_end
238
+ busy_stop
239
+ puts if @in_text
240
+ @in_text = false
241
+ end
242
+
243
+ def tool_call(name, code)
244
+ busy_stop
245
+ assistant_end
246
+ first, *rest = code.lines.map(&:chomp)
247
+ puts "#{Style.paint('⏺', :green, :bold)} #{Style.bold(name)}#{Style.dim('(')}#{Style.cyan(first)}#{rest.empty? ? Style.dim(')') : ''}"
248
+ rest.each_with_index { |l, i| puts " #{Style.cyan(l)}#{i == rest.size - 1 ? Style.dim(')') : ''}" }
249
+ end
250
+
251
+ def tool_result(text, error: false)
252
+ busy_stop
253
+ lines = text.to_s.lines.map(&:chomp)
254
+ lines = lines.first(12) + ["… (#{lines.size - 12} more lines)"] if lines.size > 13
255
+ lines.each_with_index do |l, i|
256
+ prefix = i.zero? ? " #{Style.dim('⎿')} " : " "
257
+ puts "#{prefix}#{error ? Style.red(l) : Style.dim(l)}"
258
+ end
259
+ end
260
+ end
261
+
262
+ # A braille spinner with a label and, after a while, the elapsed time.
263
+ # Runs in its own thread so CPU-bound work in the main thread still
264
+ # lets it turn (Ruby switches threads every 100ms).
265
+ class Spinner
266
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
267
+
268
+ def initialize(out, label, delay: 0.15, interval: 0.08)
269
+ @out = out
270
+ @label = label
271
+ @delay = delay
272
+ @interval = interval
273
+ @shown = false
274
+ end
275
+
276
+ def start
277
+ @started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
278
+ @thread = Thread.new do
279
+ sleep @delay
280
+ i = 0
281
+ loop do
282
+ draw(FRAMES[i % FRAMES.size])
283
+ i += 1
284
+ sleep @interval
285
+ end
286
+ end
287
+ @thread.report_on_exception = false
288
+ self
289
+ end
290
+
291
+ def stop
292
+ @thread&.kill
293
+ @thread&.join(0.2)
294
+ @thread = nil
295
+ if @shown
296
+ @out.print "\r\e[2K"
297
+ @out.flush
298
+ @shown = false
299
+ end
300
+ elapsed
301
+ end
302
+
303
+ def elapsed = @started ? Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started : 0.0
304
+
305
+ private
306
+
307
+ def draw(frame)
308
+ secs = elapsed
309
+ time = secs >= 2 ? Style.dim(" #{secs.round}s") : ""
310
+ @out.print "\r\e[2K#{Style.magenta(frame)} #{Style.dim("#{@label}…")}#{time}"
311
+ @out.flush
312
+ @shown = true
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ module Chat
5
+ # Usage hints for errors raised inside rcas, taken from the source: the
6
+ # `def` line of the method the user called and the comment block above
7
+ # it (rcas documents every public function that way).
8
+ #
9
+ # Usage.hint(error) # => ["usage: sum(f, k = nil, from = nil, to = nil, **range)",
10
+ # # "sum(k**2, k, 1, n) or sum(k**2, k: 1..n); an endless range means infinity"]
11
+ module Usage
12
+ LIB = File.expand_path("../..", __dir__)
13
+ USER_FRAME = "(rcas)"
14
+ MAX_DOC_LINES = 4
15
+
16
+ module_function
17
+
18
+ # Lines to print under an error, or nil when there is nothing to say.
19
+ # Only a plain ArgumentError counts: subclasses such as DomainError
20
+ # describe the mathematics, not the call.
21
+ # +user_frame+ is the file name the front end evaluates input under.
22
+ def hint(error, user_frame: USER_FRAME)
23
+ return nil unless error.instance_of?(ArgumentError)
24
+ frame = entry_frame(error, user_frame) or return nil
25
+ info = describe(frame.path, frame.lineno) or return nil
26
+ lines = ["usage: #{info[:signature]}"]
27
+ lines.concat(info[:doc].first(MAX_DOC_LINES))
28
+ lines
29
+ end
30
+
31
+ # The outermost rcas frame that the user's code called directly.
32
+ def entry_frame(error, user_frame = USER_FRAME)
33
+ locations = error.backtrace_locations or return nil
34
+ before_user = locations.take_while { |l| l.path != user_frame }
35
+ return nil if before_user.size == locations.size
36
+ before_user.reverse.find { |l| l.path.start_with?(LIB) }
37
+ end
38
+
39
+ # => { name:, signature:, doc: [...] } for the method whose body
40
+ # contains +lineno+ of +path+.
41
+ def describe(path, lineno)
42
+ lines = File.readlines(path)
43
+ i = lineno - 1
44
+ i -= 1 while i >= 0 && !lines[i].match?(/^\s*def\s/)
45
+ return nil if i.negative?
46
+ m = lines[i].match(/def\s+(?:self\.)?(?<name>[A-Za-z_]\w*[?!=]?|[+\-*\/%<>=!\[\]~^&|]+@?)\s*(?:\((?<params>.*?)\))?/)
47
+ return nil unless m
48
+ doc = []
49
+ j = i - 1
50
+ while j >= 0 && lines[j].match?(/^\s*#/)
51
+ doc.unshift(lines[j].sub(/^\s*#\s?/, "").rstrip)
52
+ j -= 1
53
+ end
54
+ params = m[:params].to_s.strip
55
+ signature = params.empty? ? m[:name] : "#{m[:name]}(#{params})"
56
+ { name: m[:name], signature: signature, doc: doc }
57
+ rescue SystemCallError, IOError
58
+ nil
59
+ end
60
+ end
61
+ end
62
+ end