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.
- checksums.yaml +7 -0
- data/CITATION.cff +17 -0
- data/DESIGN.md +783 -0
- data/LICENSE +21 -0
- data/MANUAL.md +6265 -0
- data/README.md +267 -0
- data/bin/rcas +9 -0
- data/bin/rcas-app +9 -0
- data/bin/rcas-chat +9 -0
- data/lib/rcas/algebraic.rb +481 -0
- data/lib/rcas/analysis.rb +966 -0
- data/lib/rcas/app/launcher.rb +203 -0
- data/lib/rcas/app/public/app.css +402 -0
- data/lib/rcas/app/public/app.js +449 -0
- data/lib/rcas/app/public/index.html +46 -0
- data/lib/rcas/app/server.rb +220 -0
- data/lib/rcas/app/window.rb +94 -0
- data/lib/rcas/app/worksheet.rb +290 -0
- data/lib/rcas/app.rb +168 -0
- data/lib/rcas/background.rb +758 -0
- data/lib/rcas/chat/assistant.rb +199 -0
- data/lib/rcas/chat/picker.rb +164 -0
- data/lib/rcas/chat/repl.rb +583 -0
- data/lib/rcas/chat/session.rb +137 -0
- data/lib/rcas/chat/settings.rb +71 -0
- data/lib/rcas/chat/style.rb +30 -0
- data/lib/rcas/chat/tool.rb +53 -0
- data/lib/rcas/chat/ui.rb +316 -0
- data/lib/rcas/chat/usage.rb +62 -0
- data/lib/rcas/chat/workspace.rb +132 -0
- data/lib/rcas/chat.rb +54 -0
- data/lib/rcas/coefficients.rb +170 -0
- data/lib/rcas/combinatorics.rb +274 -0
- data/lib/rcas/complex_parts.rb +160 -0
- data/lib/rcas/constants.rb +129 -0
- data/lib/rcas/core_ext.rb +35 -0
- data/lib/rcas/decide.rb +501 -0
- data/lib/rcas/decompositions.rb +241 -0
- data/lib/rcas/differentiate.rb +144 -0
- data/lib/rcas/discussion.rb +558 -0
- data/lib/rcas/distributions.rb +980 -0
- data/lib/rcas/dixon.rb +95 -0
- data/lib/rcas/docs.rb +321 -0
- data/lib/rcas/domains.rb +728 -0
- data/lib/rcas/expand.rb +174 -0
- data/lib/rcas/expression.rb +613 -0
- data/lib/rcas/factor.rb +605 -0
- data/lib/rcas/finite_field.rb +577 -0
- data/lib/rcas/fourier.rb +118 -0
- data/lib/rcas/fps.rb +678 -0
- data/lib/rcas/fraction.rb +126 -0
- data/lib/rcas/functions.rb +1136 -0
- data/lib/rcas/gcd.rb +112 -0
- data/lib/rcas/geometry.rb +266 -0
- data/lib/rcas/groebner.rb +162 -0
- data/lib/rcas/hold.rb +277 -0
- data/lib/rcas/hypothesis.rb +364 -0
- data/lib/rcas/inequalities.rb +689 -0
- data/lib/rcas/integral_functions.rb +260 -0
- data/lib/rcas/integrate.rb +1589 -0
- data/lib/rcas/integrate_substitutions.rb +434 -0
- data/lib/rcas/interpolate.rb +40 -0
- data/lib/rcas/irb.rb +146 -0
- data/lib/rcas/laplace.rb +159 -0
- data/lib/rcas/latex.rb +556 -0
- data/lib/rcas/lattice.rb +172 -0
- data/lib/rcas/linear_algebra.rb +117 -0
- data/lib/rcas/linear_program.rb +416 -0
- data/lib/rcas/lint.rb +79 -0
- data/lib/rcas/matrix.rb +531 -0
- data/lib/rcas/matrix_multiply.rb +202 -0
- data/lib/rcas/multimodular.rb +286 -0
- data/lib/rcas/named_polynomials.rb +274 -0
- data/lib/rcas/number_theory.rb +443 -0
- data/lib/rcas/numerics.rb +825 -0
- data/lib/rcas/ode.rb +488 -0
- data/lib/rcas/openmath/objects.rb +364 -0
- data/lib/rcas/openmath/phrasebook.rb +551 -0
- data/lib/rcas/openmath/popcorn.rb +518 -0
- data/lib/rcas/openmath/xml.rb +309 -0
- data/lib/rcas/openmath.rb +49 -0
- data/lib/rcas/petkovsek.rb +165 -0
- data/lib/rcas/piecewise.rb +488 -0
- data/lib/rcas/plot.rb +763 -0
- data/lib/rcas/plot3d.rb +419 -0
- data/lib/rcas/poly_matrix.rb +318 -0
- data/lib/rcas/poly_recurrence.rb +117 -0
- data/lib/rcas/polynomial.rb +466 -0
- data/lib/rcas/precision.rb +925 -0
- data/lib/rcas/printer.rb +150 -0
- data/lib/rcas/product.rb +155 -0
- data/lib/rcas/q_difference.rb +296 -0
- data/lib/rcas/q_functions.rb +158 -0
- data/lib/rcas/q_summation.rb +308 -0
- data/lib/rcas/q_zeilberger.rb +199 -0
- data/lib/rcas/random.rb +506 -0
- data/lib/rcas/rational_function.rb +186 -0
- data/lib/rcas/recurrence.rb +323 -0
- data/lib/rcas/render.rb +431 -0
- data/lib/rcas/results.rb +192 -0
- data/lib/rcas/scalar.rb +219 -0
- data/lib/rcas/series.rb +726 -0
- data/lib/rcas/simplify.rb +649 -0
- data/lib/rcas/solve.rb +2002 -0
- data/lib/rcas/special.rb +163 -0
- data/lib/rcas/statistics.rb +175 -0
- data/lib/rcas/steps.rb +835 -0
- data/lib/rcas/summation.rb +532 -0
- data/lib/rcas/trig.rb +264 -0
- data/lib/rcas/van_hoeij.rb +241 -0
- data/lib/rcas/vector.rb +175 -0
- data/lib/rcas/vector_calculus.rb +411 -0
- data/lib/rcas/version.rb +5 -0
- data/lib/rcas/zeilberger.rb +358 -0
- data/lib/rcas.rb +91 -0
- data/package.json +8 -0
- metadata +206 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCAS
|
|
4
|
+
module Chat
|
|
5
|
+
# The conversation with Claude. Streams replies, lets the SDK's tool
|
|
6
|
+
# runner execute rcas_eval calls, and keeps the message history.
|
|
7
|
+
class Assistant
|
|
8
|
+
DEFAULT_MODEL = ENV.fetch("RCAS_MODEL", "claude-opus-5")
|
|
9
|
+
FALLBACK_MODEL = "claude-opus-4-8"
|
|
10
|
+
FALLBACK_BETA = "server-side-fallback-2026-06-01"
|
|
11
|
+
|
|
12
|
+
# USD per million input / output tokens, for /cost.
|
|
13
|
+
PRICES = {
|
|
14
|
+
"claude-opus-5" => [5.0, 25.0], "claude-opus-4-8" => [5.0, 25.0], "claude-opus-4-7" => [5.0, 25.0],
|
|
15
|
+
"claude-fable-5-1" => [10.0, 50.0], "claude-fable-5" => [10.0, 50.0],
|
|
16
|
+
"claude-sonnet-5" => [2.0, 10.0], "claude-sonnet-4-6" => [3.0, 15.0], "claude-haiku-4-5" => [1.0, 5.0]
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
SYSTEM = <<~PROMPT
|
|
20
|
+
You are the assistant inside rcas-chat, a terminal front end for rcas, a small computer algebra system written in Ruby. The user types Ruby directly or asks questions in plain language. You answer by computing with the rcas_eval tool, which evaluates Ruby in the user's live session: their variables and assumptions are visible to you and whatever you define persists.
|
|
21
|
+
|
|
22
|
+
How to work:
|
|
23
|
+
- Compute with rcas_eval rather than doing algebra by hand, and check claims by evaluating them.
|
|
24
|
+
- The terminal typesets results you display with show: true. Use show: true for the final result(s) the user asked for, and do not retype those expressions in your reply.
|
|
25
|
+
- Keep replies short: the result, then at most a sentence or two of explanation or a hint about what else the user could ask. No headings, no bullet lists unless the user asks for a comparison.
|
|
26
|
+
- When a call fails, read the error, fix the code and retry. If rcas cannot do something, say so plainly and suggest the nearest thing it can do.
|
|
27
|
+
- Write ordinary Ruby: ** is power, 1/2r is the rational one half, a bare lowercase name or :x is an indeterminate (a symbolic unknown). Use assume(x: ZZ) to declare domains when a computation needs them.
|
|
28
|
+
|
|
29
|
+
rcas reference:
|
|
30
|
+
- Expressions: Symbols are indeterminates; + - * / ** build trees exactly as written; nothing is rewritten until asked. e.simplify, e.expand, e.factor, e.diff(x [, n]), e.integrate(x), e.subs(x: y + 1) or e.subs(pattern => replacement), e.call(x: 3) (a number when fully bound), e.evalf(x: 1.5), e.variables, e.domain, e.to_poly([ring]), e.to_sexp, e.to_latex. Functions: sin cos tan atan sinh cosh exp log sqrt; log is the natural logarithm. Numbers stay exact (Integer/Rational).
|
|
31
|
+
- Number sets: NN ZZ QQ RR CC (include?, ===, <, <=). Domains of variables: x.in(ZZ), assume(x: ZZ, y: RR), assumptions, forget; (x / 2).domain infers the smallest set.
|
|
32
|
+
- Polynomial rings: ZZ[x], QQ[x, y], RR[t]; R.(expr) converts to an RCAS::Polynomial. f + g, f * g, f**n, f / g (exact), f.divmod(g), f % g (univariate), f.gcd(g), f.lcm(g), f.xgcd(g) => [g, s, t], f.degree([var]), f.coefficients (univariate), f.coeff(*exps), f.leading_coefficient, f.derivative([var]), f.integrate([var]), f.factor => RCAS::Factorization (unit, factors as [poly, multiplicity], expand, to_expr), f.irreducible?, f.squarefree_decomposition, f.roots (rational roots), f.resultant(g [, var]), f.discriminant([var]), f.coprime?(g), f.content, f.primitive_part, f.monic, f.to_expr, f.ring. Factorization is exact over ZZ and QQ only. (x**2 - y**2).factor works straight on expressions. R.fraction_field is Frac(R).
|
|
33
|
+
- Vectors and matrices: V = QQ**3; v = V[1, 2, 3]; v + w, 2 * v, v / 2, v * w (dot), v.cross(w), v.norm. A = QQ**[2, 2]; a = A[[1, 2], [3, 4]]; also matrix([[1, 2], [3, 4]]), matrix(RR, rows), vector(1, 2), vector(QQ, 1, 2). a.det, a.trace, a.rank, a.inverse, a.transpose, a**n, a * b, a * v, a.charpoly(:l), a.solve([5, 6]), a.rref, a.kernel, a.identity?, a.symmetric?, a.adjugate, a.cofactor(i, j), A.identity, A.zero. Symbolic entries need declared variables (assume(t: RR)). Numeric matrices over ZZ/QQ are exact.
|
|
34
|
+
- Display: obj.to_latex gives LaTeX; show(obj) typesets it in the terminal.
|
|
35
|
+
PROMPT
|
|
36
|
+
|
|
37
|
+
attr_accessor :model, :fallbacks, :on_tool_call
|
|
38
|
+
attr_accessor :messages
|
|
39
|
+
attr_reader :usage, :ui
|
|
40
|
+
|
|
41
|
+
def initialize(workspace, ui, model: DEFAULT_MODEL, client: nil, fallbacks: ENV["RCAS_FALLBACKS"] != "0", runner_factory: nil)
|
|
42
|
+
@workspace = workspace
|
|
43
|
+
@ui = ui
|
|
44
|
+
@model = model
|
|
45
|
+
@client = client
|
|
46
|
+
@fallbacks = fallbacks
|
|
47
|
+
@runner_factory = runner_factory
|
|
48
|
+
@messages = []
|
|
49
|
+
@usage = Hash.new(0)
|
|
50
|
+
# a scripted runner (the tests) needs the tool as much as real credentials do
|
|
51
|
+
Chat.load_anthropic if runner_factory || self.class.credentials?
|
|
52
|
+
return unless defined?(EvalTool)
|
|
53
|
+
@tool = EvalTool.new(workspace, ui)
|
|
54
|
+
@tool.on_call = ->(code, result) { @on_tool_call&.call(code, result) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Loads the gem on first use (Chat.load_anthropic): a session nobody
|
|
58
|
+
# asks Claude in never loads it.
|
|
59
|
+
def self.gem_available? = Chat.load_anthropic
|
|
60
|
+
|
|
61
|
+
# Credentials are resolved by the SDK (ANTHROPIC_API_KEY, or a profile
|
|
62
|
+
# from `ant auth login`); we only know for sure once a request is made.
|
|
63
|
+
def available?
|
|
64
|
+
return true if @runner_factory || @client
|
|
65
|
+
self.class.configured?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Gem installed and credentials present: only then does the chat mention
|
|
69
|
+
# Claude at all. The credentials are asked first, so that without them
|
|
70
|
+
# the gem is never loaded.
|
|
71
|
+
def self.configured? = credentials? && gem_available?
|
|
72
|
+
|
|
73
|
+
def self.credentials?
|
|
74
|
+
!ENV["ANTHROPIC_API_KEY"].to_s.empty? || !ENV["ANTHROPIC_AUTH_TOKEN"].to_s.empty? ||
|
|
75
|
+
File.directory?(File.join(Dir.home, ".config", "anthropic"))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def client = @client ||= Anthropic::Client.new
|
|
79
|
+
|
|
80
|
+
def reset = @messages = []
|
|
81
|
+
|
|
82
|
+
def params
|
|
83
|
+
p = {
|
|
84
|
+
model: model,
|
|
85
|
+
max_tokens: 16_000,
|
|
86
|
+
system_: [{ type: "text", text: SYSTEM, cache_control: { type: "ephemeral" } }],
|
|
87
|
+
tools: [@tool],
|
|
88
|
+
messages: @messages,
|
|
89
|
+
max_iterations: 16
|
|
90
|
+
}
|
|
91
|
+
if fallbacks && model.start_with?("claude-opus-5", "claude-fable")
|
|
92
|
+
p[:betas] = [FALLBACK_BETA]
|
|
93
|
+
p[:fallbacks] = [{ model: FALLBACK_MODEL }]
|
|
94
|
+
end
|
|
95
|
+
p
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Ask Claude; streams the reply and runs any tool calls. Returns the
|
|
99
|
+
# final reply text (also printed), or nil when interrupted.
|
|
100
|
+
def ask(text, note: nil)
|
|
101
|
+
raise Error, "the anthropic gem is not installed: gem install anthropic" unless self.class.gem_available?
|
|
102
|
+
snapshot = @messages.dup
|
|
103
|
+
prompt = text.dup
|
|
104
|
+
prompt << "\n\n(This did not evaluate as Ruby: #{note})" if note
|
|
105
|
+
prompt << context
|
|
106
|
+
@messages << { role: :user, content: prompt }
|
|
107
|
+
|
|
108
|
+
last = nil
|
|
109
|
+
runner = @runner_factory ? @runner_factory.call(params) : client.beta.messages.tool_runner(params)
|
|
110
|
+
@ui.assistant_start
|
|
111
|
+
@ui.busy_start("thinking")
|
|
112
|
+
runner.each_streaming do |stream|
|
|
113
|
+
stream.each { |event| handle_event(event) }
|
|
114
|
+
last = stream.accumulated_message
|
|
115
|
+
record_usage(last)
|
|
116
|
+
end
|
|
117
|
+
@ui.assistant_end
|
|
118
|
+
history = runner.params[:messages].to_a
|
|
119
|
+
history += [{ role: :assistant, content: last.content }] if last && history.last&.[](:role) != :assistant
|
|
120
|
+
@messages = history
|
|
121
|
+
note_stop_reason(last) if last
|
|
122
|
+
last ? last.content.select { |b| b.type == :text }.map(&:text).join : ""
|
|
123
|
+
rescue Interrupt
|
|
124
|
+
@ui.assistant_end
|
|
125
|
+
@ui.info("(interrupted)")
|
|
126
|
+
@messages = snapshot
|
|
127
|
+
nil
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
@ui.assistant_end
|
|
130
|
+
@messages = snapshot
|
|
131
|
+
raise Error, describe(e)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def handle_event(event)
|
|
135
|
+
@ui.assistant_text(event.text) if event.type == :text
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def note_stop_reason(message)
|
|
139
|
+
case message.stop_reason
|
|
140
|
+
when :refusal
|
|
141
|
+
details = message.respond_to?(:stop_details) && message.stop_details ? " (#{message.stop_details.category})" : ""
|
|
142
|
+
@ui.info("Claude declined to answer this#{details}.")
|
|
143
|
+
when :max_tokens
|
|
144
|
+
@ui.info("(reply cut off at the token limit)")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def record_usage(message)
|
|
149
|
+
u = message.usage
|
|
150
|
+
return unless u
|
|
151
|
+
@usage[:input] += u.input_tokens.to_i
|
|
152
|
+
@usage[:output] += u.output_tokens.to_i
|
|
153
|
+
@usage[:cache_read] += u.cache_read_input_tokens.to_i if u.respond_to?(:cache_read_input_tokens)
|
|
154
|
+
@usage[:cache_write] += u.cache_creation_input_tokens.to_i if u.respond_to?(:cache_creation_input_tokens)
|
|
155
|
+
@usage[:requests] += 1
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def cost
|
|
159
|
+
input, output = PRICES[model] || PRICES.find { |k, _| model.start_with?(k) }&.last
|
|
160
|
+
lines = ["requests: #{@usage[:requests]} input tokens: #{@usage[:input]} (cache read #{@usage[:cache_read]}, cache write #{@usage[:cache_write]}) output tokens: #{@usage[:output]}"]
|
|
161
|
+
if input
|
|
162
|
+
usd = (@usage[:input] * input + @usage[:output] * output) / 1_000_000.0
|
|
163
|
+
lines << format("approx. list price for %s: $%.4f (cache reads are billed lower)", model, usd)
|
|
164
|
+
end
|
|
165
|
+
lines.join("\n")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# What the session currently holds, appended to each question.
|
|
169
|
+
def context
|
|
170
|
+
locals = @workspace.locals.reject { |_, v| v.is_a?(Symbol) }.map { |k, v| "#{k} = #{clip(v.to_s.gsub("\n", ' '))}" }
|
|
171
|
+
symbols = @workspace.locals.select { |k, v| v.is_a?(Symbol) && v == k }.keys
|
|
172
|
+
assumptions = RCAS.assumptions.values.flatten.map(&:to_s)
|
|
173
|
+
parts = []
|
|
174
|
+
parts << "variables: #{symbols.join(', ')}" unless symbols.empty?
|
|
175
|
+
parts << "locals: #{locals.join('; ')}" unless locals.empty?
|
|
176
|
+
parts << "assumptions: #{assumptions.join(', ')}" unless assumptions.empty?
|
|
177
|
+
parts.empty? ? "" : "\n\n[session #{parts.join(' | ')}]"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def clip(text, max = 120) = text.size > max ? "#{text[0, max]}…" : text
|
|
181
|
+
|
|
182
|
+
def describe(error)
|
|
183
|
+
return "#{error.class}: #{error.message}" unless defined?(Anthropic::Errors)
|
|
184
|
+
case error
|
|
185
|
+
when Anthropic::Errors::AuthenticationError
|
|
186
|
+
"no valid Anthropic credentials. Set ANTHROPIC_API_KEY (or run `ant auth login`) and try again."
|
|
187
|
+
when Anthropic::Errors::RateLimitError
|
|
188
|
+
"rate limited by the API; wait a moment and try again."
|
|
189
|
+
when Anthropic::Errors::APIStatusError
|
|
190
|
+
"API error #{error.status} (#{error.type}): #{error.message.lines.first&.strip}"
|
|
191
|
+
when Anthropic::Errors::APIConnectionError
|
|
192
|
+
"could not reach the Anthropic API: #{error.message.lines.first&.strip}"
|
|
193
|
+
else
|
|
194
|
+
"#{error.class}: #{error.message.lines.first&.strip}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module RCAS
|
|
7
|
+
module Chat
|
|
8
|
+
# An interactive list of saved sessions, in the style of Claude Code's
|
|
9
|
+
# /resume: arrow keys move, typing filters, Enter picks, Esc cancels.
|
|
10
|
+
# Without a terminal it degrades to a numbered list read from stdin.
|
|
11
|
+
class Picker
|
|
12
|
+
UP = ["\e[A", "\eOA", "\x10"].freeze # up arrow, Ctrl-P
|
|
13
|
+
DOWN = ["\e[B", "\eOB", "\x0E"].freeze # down arrow, Ctrl-N
|
|
14
|
+
ENTER = ["\r", "\n"].freeze
|
|
15
|
+
QUIT = ["\e", "\x03", "\x04"].freeze # Esc, Ctrl-C, Ctrl-D
|
|
16
|
+
BACK = ["\x7F", "\b"].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :sessions
|
|
19
|
+
|
|
20
|
+
def initialize(sessions, input: $stdin, output: $stdout, title: "Resume a session")
|
|
21
|
+
@sessions = sessions
|
|
22
|
+
@input = input
|
|
23
|
+
@output = output
|
|
24
|
+
@title = title
|
|
25
|
+
@query = +""
|
|
26
|
+
@selected = 0
|
|
27
|
+
@drawn = 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# => the chosen Session, or nil
|
|
31
|
+
def run
|
|
32
|
+
return nil if @sessions.empty?
|
|
33
|
+
return fallback unless tty?
|
|
34
|
+
@output.print "\e[?25l"
|
|
35
|
+
@input.raw(intr: false) do
|
|
36
|
+
loop do
|
|
37
|
+
draw
|
|
38
|
+
case (key = read_key)
|
|
39
|
+
when *QUIT then return nil
|
|
40
|
+
when *ENTER then return matches[@selected]
|
|
41
|
+
when *UP then @selected -= 1
|
|
42
|
+
when *DOWN then @selected += 1
|
|
43
|
+
when *BACK then @query.chop!
|
|
44
|
+
else @query << key if key && key.match?(/\A[[:print:]]\z/)
|
|
45
|
+
end
|
|
46
|
+
@selected = @selected.clamp(0, [matches.size - 1, 0].max)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
ensure
|
|
50
|
+
clear
|
|
51
|
+
@output.print "\e[?25h"
|
|
52
|
+
@output.flush
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def matches
|
|
56
|
+
return @sessions if @query.empty?
|
|
57
|
+
q = @query.downcase
|
|
58
|
+
@sessions.select { |s| [s.title, s.first_input, s.id].compact.any? { |t| t.downcase.include?(q) } }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ---- drawing ----------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def draw
|
|
64
|
+
list = matches
|
|
65
|
+
visible = visible_rows
|
|
66
|
+
first = [[@selected - visible + 1, 0].max, [list.size - visible, 0].max].min
|
|
67
|
+
lines = [Style.bold(@title) + Style.dim(" ↑/↓ move · Enter select · Esc cancel · type to filter")]
|
|
68
|
+
lines << ""
|
|
69
|
+
if list.empty?
|
|
70
|
+
lines << Style.dim(" no session matches #{@query.inspect}")
|
|
71
|
+
else
|
|
72
|
+
list[first, visible].each_with_index do |session, i|
|
|
73
|
+
index = first + i
|
|
74
|
+
lines.concat(row(session, index, index == @selected))
|
|
75
|
+
end
|
|
76
|
+
lines << Style.dim(" … #{list.size - first - visible} more") if first + visible < list.size
|
|
77
|
+
end
|
|
78
|
+
lines << ""
|
|
79
|
+
lines << (@query.empty? ? Style.dim(" filter: type to search") : " #{Style.dim('filter:')} #{@query}▌")
|
|
80
|
+
redraw(lines)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def row(session, index, selected)
|
|
84
|
+
pointer = selected ? Style.cyan("❯") : " "
|
|
85
|
+
head = "#{pointer} #{Style.dim((index + 1).to_s.rjust(2) + '.')} #{session.relative_time.ljust(15)} #{"#{session.turns} inputs".ljust(11)} #{Style.bold(session.name)}"
|
|
86
|
+
head = Style.paint(Style.strip(head), :cyan) if selected
|
|
87
|
+
preview = session.first_input
|
|
88
|
+
lines = [head]
|
|
89
|
+
lines << " #{Style.dim(preview)}" if preview && preview != session.title
|
|
90
|
+
lines
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def redraw(lines)
|
|
94
|
+
width = columns - 1
|
|
95
|
+
@output.print "\e[#{@drawn}A" if @drawn.positive?
|
|
96
|
+
# The terminal is in raw mode while the picker runs, so a bare newline
|
|
97
|
+
# would not return to column 0: start each row with \r and end it with \r\n.
|
|
98
|
+
lines.each { |l| @output.print "\r\e[2K#{truncate(l, width)}\r\n" }
|
|
99
|
+
# The list may have shrunk: blank any rows left from the last frame.
|
|
100
|
+
(@drawn - lines.size).clamp(0, 100).times { @output.print "\r\e[2K\r\n" }
|
|
101
|
+
extra = [@drawn - lines.size, 0].max
|
|
102
|
+
@output.print "\e[#{extra}A" if extra.positive?
|
|
103
|
+
@drawn = lines.size
|
|
104
|
+
@output.flush
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def clear
|
|
108
|
+
return unless @drawn.positive?
|
|
109
|
+
@output.print "\r\e[#{@drawn}A\e[J"
|
|
110
|
+
@drawn = 0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def truncate(line, width)
|
|
114
|
+
return line if Style.strip(line).size <= width
|
|
115
|
+
"#{Style.strip(line)[0, [width - 1, 0].max]}…"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def visible_rows
|
|
119
|
+
rows = @output.respond_to?(:winsize) ? @output.winsize[0] : 24
|
|
120
|
+
((rows - 6) / 2).clamp(3, 10)
|
|
121
|
+
rescue StandardError
|
|
122
|
+
6
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def columns
|
|
126
|
+
cols = @output.respond_to?(:winsize) ? @output.winsize[1] : 0
|
|
127
|
+
cols.positive? ? cols : 100
|
|
128
|
+
rescue StandardError
|
|
129
|
+
100
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ---- input ------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def tty? = [@input, @output].all? { |io| io.respond_to?(:tty?) && io.tty? } && @input.respond_to?(:raw)
|
|
135
|
+
|
|
136
|
+
# One key: a character, or a whole escape sequence (Esc, then "[" or
|
|
137
|
+
# "O", then the final letter). A lone Esc is one that nothing follows.
|
|
138
|
+
def read_key
|
|
139
|
+
key = @input.getc
|
|
140
|
+
return key if key.nil? || key != "\e" || !pending_input?
|
|
141
|
+
second = @input.getc or return key
|
|
142
|
+
key += second
|
|
143
|
+
key += @input.getc.to_s if ["[", "O"].include?(second)
|
|
144
|
+
key
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def pending_input?
|
|
148
|
+
return !@input.eof? if @input.is_a?(StringIO)
|
|
149
|
+
IO.select([@input], nil, nil, 0.05) ? true : false
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Numbered list on a plain stream.
|
|
153
|
+
def fallback
|
|
154
|
+
@output.puts @title
|
|
155
|
+
@sessions.first(20).each_with_index { |s, i| @output.puts " #{(i + 1).to_s.rjust(2)} #{s.summary}" }
|
|
156
|
+
@output.print "Resume which? [1] "
|
|
157
|
+
@output.flush
|
|
158
|
+
answer = @input.gets.to_s.strip
|
|
159
|
+
answer = "1" if answer.empty?
|
|
160
|
+
Session.find(answer, among: @sessions)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|