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,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