aids 0.1.0 → 1.0.3
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 +4 -4
- data/lib/aids.rb +3 -2
- data/lib/ansi.rb +4 -4
- data/lib/app.rb +44 -44
- data/lib/commands.rb +18 -24
- data/lib/line_editor.rb +208 -69
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e60e41121f7a2b87f9eabf7da50f3db77f1c39c29d92387aea3574a92e6879ad
|
|
4
|
+
data.tar.gz: 5530066dc015f810cf9c5e20c6d058e08349f380b7a0f7ea0267fea6ba1c2f8c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: db161e59e16a9a9fdb06a53bad67206e536d12093dd4b22b6f1fbc9fb9cba277ae5cbec3309d28b300e33cb6d1020fcb5ef4b0a82aae58a4dc0cef26480a86b9
|
|
7
|
+
data.tar.gz: 835b58458d85c051651a45f3574d24f6d80567d528666a4b5545331f2ea34ebcb05c9f704cd48e58328b9099a3e4b4ff8de0efde0d08ef820b9a86e9e2e3928b
|
data/lib/aids.rb
CHANGED
|
@@ -107,15 +107,16 @@ module AI
|
|
|
107
107
|
|
|
108
108
|
# ── Entry point ──────────────────────────────────────────────────────
|
|
109
109
|
def self.run(argv = ARGV)
|
|
110
|
-
resume = false
|
|
110
|
+
resume = false; temp = false
|
|
111
111
|
rest = OptionParser.new { |o|
|
|
112
112
|
o.on("-c", "--continue") { resume = true }
|
|
113
|
+
o.on("-t", "--temp") { temp = true }
|
|
113
114
|
o.on("--history") { exec("lf", DATA_DIR) }
|
|
114
115
|
o.on("--clean") { clean!; exit }
|
|
115
116
|
}.order(argv)
|
|
116
117
|
|
|
117
118
|
key = ENV["DEEPSEEK_API_KEY"] or abort "#{Ansi.fg(196)}DEEPSEEK_API_KEY not set#{Ansi.reset}"
|
|
118
|
-
app = App.new(key: key, profile: Profile.load, resume: resume)
|
|
119
|
+
app = App.new(key: key, profile: Profile.load, resume: resume, temp: temp)
|
|
119
120
|
|
|
120
121
|
piped = $stdin.tty? ? "" : $stdin.read.strip
|
|
121
122
|
text = [piped, rest.join(" ")].map(&:strip).reject(&:empty?).join(" ")
|
data/lib/ansi.rb
CHANGED
|
@@ -3,10 +3,10 @@ module AI
|
|
|
3
3
|
module Ansi
|
|
4
4
|
module_function
|
|
5
5
|
|
|
6
|
-
def reset
|
|
7
|
-
def bold
|
|
8
|
-
def dim
|
|
9
|
-
def fg(n)
|
|
6
|
+
def reset = "\e[0m"
|
|
7
|
+
def bold = "\e[1m"
|
|
8
|
+
def dim = "\e[2m"
|
|
9
|
+
def fg(n) = "\e[38;5;#{n}m"
|
|
10
10
|
def strip(s) = s.gsub(/\e\[[\d;]*m/, "")
|
|
11
11
|
|
|
12
12
|
def width(s)
|
data/lib/app.rb
CHANGED
|
@@ -4,16 +4,16 @@ module AI
|
|
|
4
4
|
TITLE_PROMPT = "Give a concise title (3-10 words) for this conversation. " \
|
|
5
5
|
"Respond with ONLY the title, nothing else."
|
|
6
6
|
|
|
7
|
-
DISCARD_USAGE = "usage: /discard [
|
|
7
|
+
DISCARD_USAGE = "usage: /discard [* | N | A-B | A- | -B]"
|
|
8
8
|
|
|
9
9
|
attr_reader :session, :attachments
|
|
10
10
|
|
|
11
|
-
def initialize(key:, profile:, resume: false)
|
|
12
|
-
@key = key; @profile = profile
|
|
11
|
+
def initialize(key:, profile:, resume: false, temp: false)
|
|
12
|
+
@key = key; @profile = profile; @temp = temp
|
|
13
13
|
@editor = LineEditor.new
|
|
14
14
|
@editor.completer = ->(prefix) { complete(prefix) }
|
|
15
|
-
@hl
|
|
16
|
-
@fresh
|
|
15
|
+
@hl = Highlighter.new
|
|
16
|
+
@fresh = true
|
|
17
17
|
@attachments = []
|
|
18
18
|
@title_thread = nil
|
|
19
19
|
if resume && (st = Session.all.last) && (s = Session.load(st))
|
|
@@ -33,8 +33,7 @@ module AI
|
|
|
33
33
|
|
|
34
34
|
print_attachments(attached_data) if attached_data.any?
|
|
35
35
|
puts "\n#{Ansi.fg(p.color)}#{p.icon} #{p.name}#{Ansi.reset}"
|
|
36
|
-
reply, usage =
|
|
37
|
-
begin
|
|
36
|
+
reply, usage = begin
|
|
38
37
|
AI.stream(pending, p.system, @key) { |chunk| print @hl.paint(chunk) }
|
|
39
38
|
rescue Interrupt
|
|
40
39
|
puts "\n#{Ansi.fg(220)}[interrupted — turn discarded]#{Ansi.reset}"
|
|
@@ -44,19 +43,18 @@ module AI
|
|
|
44
43
|
@session.add_turn(text, reply, usage, attachments: attached_data)
|
|
45
44
|
@attachments = []
|
|
46
45
|
AI.print_usage(usage)
|
|
47
|
-
@session.save(p)
|
|
46
|
+
@session.save(p) unless @temp
|
|
48
47
|
@fresh = false
|
|
49
|
-
Stats.record(usage)
|
|
50
|
-
kick_title_fetch
|
|
48
|
+
Stats.record(usage) unless @temp
|
|
49
|
+
kick_title_fetch unless @temp
|
|
51
50
|
end
|
|
52
51
|
|
|
53
52
|
def discard(arg)
|
|
54
53
|
arg = arg.to_s.strip
|
|
55
|
-
return discard_session if arg == "
|
|
54
|
+
return discard_session if arg == "*"
|
|
56
55
|
|
|
57
56
|
total = @session.turns
|
|
58
|
-
range =
|
|
59
|
-
if arg.empty?
|
|
57
|
+
range = if arg.empty?
|
|
60
58
|
[total, total]
|
|
61
59
|
else
|
|
62
60
|
parsed = parse_discard_arg(arg, total)
|
|
@@ -66,7 +64,7 @@ module AI
|
|
|
66
64
|
|
|
67
65
|
first, last = range
|
|
68
66
|
first = first.clamp(1, total)
|
|
69
|
-
last
|
|
67
|
+
last = last.clamp(1, total)
|
|
70
68
|
if last < first
|
|
71
69
|
notify "no turns in that range"; return
|
|
72
70
|
end
|
|
@@ -78,7 +76,7 @@ module AI
|
|
|
78
76
|
redraw
|
|
79
77
|
notify "\u2212 discarded #{removed} turn#{removed == 1 ? "" : "s"} · session is now empty"
|
|
80
78
|
else
|
|
81
|
-
@session.save(@profile)
|
|
79
|
+
@session.save(@profile) unless @temp
|
|
82
80
|
redraw
|
|
83
81
|
notify "\u2212 discarded #{removed} turn#{removed == 1 ? "" : "s"}"
|
|
84
82
|
end
|
|
@@ -94,7 +92,7 @@ module AI
|
|
|
94
92
|
return
|
|
95
93
|
end
|
|
96
94
|
@session.title = text
|
|
97
|
-
@session.save(@profile)
|
|
95
|
+
@session.save(@profile) unless @temp
|
|
98
96
|
redraw
|
|
99
97
|
notify "title set"
|
|
100
98
|
end
|
|
@@ -104,12 +102,14 @@ module AI
|
|
|
104
102
|
src = @session
|
|
105
103
|
dup = Session.new
|
|
106
104
|
dup.messages = Marshal.load(Marshal.dump(src.messages))
|
|
107
|
-
dup.usages
|
|
108
|
-
dup.title
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
dup.usages = Marshal.load(Marshal.dump(src.usages))
|
|
106
|
+
dup.title = src.title ? "#{src.title} (clone)" : "(clone)"
|
|
107
|
+
unless @temp
|
|
108
|
+
dup.save(@profile)
|
|
109
|
+
FileUtils.cp(src.history_file, dup.history_file) if File.exist?(src.history_file)
|
|
110
|
+
end
|
|
111
111
|
@session = dup
|
|
112
|
-
@fresh
|
|
112
|
+
@fresh = false
|
|
113
113
|
load_history
|
|
114
114
|
redraw
|
|
115
115
|
notify "cloned to new session"
|
|
@@ -155,19 +155,19 @@ module AI
|
|
|
155
155
|
end
|
|
156
156
|
expanded = File.expand_path(arg)
|
|
157
157
|
targets = if File.directory?(expanded)
|
|
158
|
-
|
|
159
|
-
else
|
|
160
|
-
exact = @attachments.find { |p| Paths.short(p) == arg }
|
|
161
|
-
if exact
|
|
162
|
-
[exact]
|
|
158
|
+
@attachments.select { |p| File.dirname(p) == expanded }
|
|
163
159
|
else
|
|
164
|
-
@attachments.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
160
|
+
exact = @attachments.find { |p| Paths.short(p) == arg }
|
|
161
|
+
if exact
|
|
162
|
+
[exact]
|
|
163
|
+
else
|
|
164
|
+
@attachments.select { |p|
|
|
165
|
+
File.fnmatch(arg, File.basename(p)) ||
|
|
166
|
+
File.fnmatch(arg, Paths.short(p)) ||
|
|
167
|
+
File.fnmatch(arg, p)
|
|
168
|
+
}
|
|
169
|
+
end
|
|
169
170
|
end
|
|
170
|
-
end
|
|
171
171
|
if targets.empty?
|
|
172
172
|
notify "no matching attachments: #{arg}"
|
|
173
173
|
return
|
|
@@ -187,12 +187,12 @@ module AI
|
|
|
187
187
|
redraw if @session.messages.any?
|
|
188
188
|
loop do
|
|
189
189
|
puts format_attachment_summary if @attachments.any?
|
|
190
|
-
result = begin; @editor.readline(build_prompt);
|
|
190
|
+
result = begin; @editor.readline(build_prompt); rescue Interrupt; break; end
|
|
191
191
|
case result
|
|
192
|
-
when nil
|
|
193
|
-
when :sess_next
|
|
194
|
-
when :sess_prev
|
|
195
|
-
when :redraw
|
|
192
|
+
when nil then break
|
|
193
|
+
when :sess_next then switch_session(+1)
|
|
194
|
+
when :sess_prev then switch_session(-1)
|
|
195
|
+
when :redraw then redraw
|
|
196
196
|
when :del_session then delete_session
|
|
197
197
|
when String
|
|
198
198
|
t = result.strip
|
|
@@ -251,10 +251,10 @@ module AI
|
|
|
251
251
|
def complete(prefix)
|
|
252
252
|
return nil unless Commands.slash?(prefix)
|
|
253
253
|
if (m = prefix.match(/\A(\/\S+)\s+/))
|
|
254
|
-
name
|
|
254
|
+
name = m[1]
|
|
255
255
|
arg_start = m.end(0)
|
|
256
|
-
arg
|
|
257
|
-
cmd
|
|
256
|
+
arg = prefix[arg_start..]
|
|
257
|
+
cmd = Commands.find(name)
|
|
258
258
|
return nil unless cmd && cmd.available?(self) && cmd.arg_complete
|
|
259
259
|
{ matches: cmd.arg_complete.call(self, arg), start: arg_start }
|
|
260
260
|
else
|
|
@@ -264,7 +264,7 @@ module AI
|
|
|
264
264
|
|
|
265
265
|
def handle_command(input)
|
|
266
266
|
case Commands.dispatch(self, input)
|
|
267
|
-
when :unknown
|
|
267
|
+
when :unknown then notify "unknown command: #{input.split.first}"
|
|
268
268
|
when :unavailable then notify "not available right now: #{input.split.first}"
|
|
269
269
|
end
|
|
270
270
|
end
|
|
@@ -279,7 +279,7 @@ module AI
|
|
|
279
279
|
|
|
280
280
|
def center(str)
|
|
281
281
|
cols = IO.console&.winsize&.last || 80
|
|
282
|
-
pad
|
|
282
|
+
pad = [(cols - Ansi.width(str)) / 2, 0].max
|
|
283
283
|
(" " * pad) + str
|
|
284
284
|
end
|
|
285
285
|
|
|
@@ -368,7 +368,7 @@ module AI
|
|
|
368
368
|
def draw_meta
|
|
369
369
|
all = Session.all
|
|
370
370
|
idx = all.index(@session.stamp)
|
|
371
|
-
n
|
|
371
|
+
n = idx ? idx + 1 : all.length + 1
|
|
372
372
|
total = [all.length, n].max
|
|
373
373
|
puts center("#{CMT_STYLE}session #{n}/#{total} \u00b7 #{@session.human_time}#{Ansi.reset}")
|
|
374
374
|
end
|
|
@@ -406,7 +406,7 @@ module AI
|
|
|
406
406
|
end
|
|
407
407
|
|
|
408
408
|
def save_history
|
|
409
|
-
return if @editor.history.empty?
|
|
409
|
+
return if @temp || @editor.history.empty?
|
|
410
410
|
FileUtils.mkdir_p(META_DIR)
|
|
411
411
|
File.write(@session.history_file, @editor.history.last(1000).join("\n"))
|
|
412
412
|
end
|
data/lib/commands.rb
CHANGED
|
@@ -9,11 +9,11 @@ module AI
|
|
|
9
9
|
|
|
10
10
|
def self.register(name, desc:, available: nil, arg_complete: nil, &run)
|
|
11
11
|
REGISTRY[name] = Command.new(
|
|
12
|
-
name: name, desc: desc, available: available, arg_complete: arg_complete, run: run
|
|
12
|
+
name: name, desc: desc, available: available, arg_complete: arg_complete, run: run,
|
|
13
13
|
)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def self.find(name)
|
|
16
|
+
def self.find(name) = REGISTRY[name]
|
|
17
17
|
def self.slash?(input) = input.start_with?("/")
|
|
18
18
|
|
|
19
19
|
def self.available_names(app)
|
|
@@ -33,48 +33,42 @@ module AI
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
register("/discard",
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
) do |app, args|
|
|
36
|
+
desc: "Remove turn(s): /discard [*|N|A-B|A-|-B]",
|
|
37
|
+
available: ->(app) { !app.session.empty? },
|
|
38
|
+
arg_complete: ->(_app, arg) { %w[*].select { |x| x.start_with?(arg) } }) do |app, args|
|
|
40
39
|
app.discard(args)
|
|
41
40
|
end
|
|
42
41
|
|
|
43
42
|
register("/title",
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
) do |app, args|
|
|
43
|
+
desc: "Set the session title",
|
|
44
|
+
available: ->(app) { !app.session.empty? }) do |app, args|
|
|
47
45
|
app.set_title(args.strip)
|
|
48
46
|
end
|
|
49
47
|
|
|
50
48
|
register("/clone",
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
) do |app, _|
|
|
49
|
+
desc: "Clone this session into a new active one",
|
|
50
|
+
available: ->(app) { !app.session.empty? }) do |app, _|
|
|
54
51
|
app.clone_session
|
|
55
52
|
end
|
|
56
53
|
|
|
57
54
|
register("/attach",
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
) do |app, args|
|
|
55
|
+
desc: "Attach a file or directory to the next turn",
|
|
56
|
+
arg_complete: ->(_app, arg) { Paths.complete(arg) }) do |app, args|
|
|
61
57
|
app.attach(args.strip)
|
|
62
58
|
end
|
|
63
59
|
|
|
64
60
|
register("/detach",
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
) do |app, args|
|
|
61
|
+
desc: "Remove an attachment",
|
|
62
|
+
available: ->(app) { app.attachments.any? },
|
|
63
|
+
arg_complete: ->(app, arg) {
|
|
64
|
+
app.attachments.map { |p| Paths.short(p) }.select { |s| s.start_with?(arg) }
|
|
65
|
+
}) do |app, args|
|
|
71
66
|
app.detach(args.strip)
|
|
72
67
|
end
|
|
73
68
|
|
|
74
69
|
register("/files",
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
) do |app, _|
|
|
70
|
+
desc: "List currently attached files",
|
|
71
|
+
available: ->(app) { app.attachments.any? }) do |app, _|
|
|
78
72
|
app.list_files
|
|
79
73
|
end
|
|
80
74
|
end
|
data/lib/line_editor.rb
CHANGED
|
@@ -1,64 +1,70 @@
|
|
|
1
1
|
module AI
|
|
2
|
-
# ── Mini line editor with history + Tab completion
|
|
2
|
+
# ── Mini line editor with history + Tab completion ───────────────────
|
|
3
3
|
class LineEditor
|
|
4
4
|
attr_accessor :history, :completer
|
|
5
5
|
|
|
6
6
|
SIGNALS = {
|
|
7
|
-
"\ej"
|
|
8
|
-
"\ek"
|
|
9
|
-
"\el"
|
|
7
|
+
"\ej" => :sess_next,
|
|
8
|
+
"\ek" => :sess_prev,
|
|
9
|
+
"\el" => :redraw, "\eL" => :redraw,
|
|
10
10
|
"\e[3;7~" => :del_session, "\e\e[3;5~" => :del_session,
|
|
11
|
-
"\e[3;5~" => :del_session, "\e\e[3^"
|
|
12
|
-
"\e[3^"
|
|
11
|
+
"\e[3;5~" => :del_session, "\e\e[3^" => :del_session,
|
|
12
|
+
"\e[3^" => :del_session, "\e[3;8~" => :del_session,
|
|
13
13
|
}.freeze
|
|
14
14
|
|
|
15
|
+
PASTE_STYLE = "\e[48;5;18;38;5;231m"
|
|
16
|
+
BPASTE_ON = "\e[?2004h"
|
|
17
|
+
BPASTE_OFF = "\e[?2004l"
|
|
18
|
+
|
|
15
19
|
def initialize; @history = []; @completer = nil; end
|
|
16
20
|
|
|
17
21
|
def readline(prompt)
|
|
18
|
-
@prompt = prompt; @buf = +""; @cur = 0
|
|
22
|
+
@prompt = prompt.to_s; @buf = +""; @cur = 0
|
|
19
23
|
@hpos = @history.length; @stash = nil
|
|
20
|
-
@prev_rows =
|
|
24
|
+
@prev_rows = 1; @prev_cur_row = 0
|
|
21
25
|
@cycle = nil
|
|
26
|
+
@pastes = []
|
|
27
|
+
print BPASTE_ON
|
|
22
28
|
render
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
29
|
+
begin
|
|
30
|
+
$stdin.raw do |io|
|
|
31
|
+
loop do
|
|
32
|
+
k = read_key(io) or next
|
|
33
|
+
if (sig = SIGNALS[k]) then return signal(sig) end
|
|
34
|
+
@cycle = nil unless k == "\t"
|
|
35
|
+
case k
|
|
36
|
+
when "\e[200~" then collect_paste(io)
|
|
37
|
+
when "\r", "\n" then insert("\n")
|
|
38
|
+
when "\e\r", "\e\n" then return submit
|
|
39
|
+
when "\t" then handle_tab
|
|
40
|
+
when "\x03" then end_render; print "\r\n"; raise Interrupt
|
|
41
|
+
when "\e[3~" then forward_delete
|
|
42
|
+
when "\x02", "\e[D" then move(-1)
|
|
43
|
+
when "\x06", "\e[C" then move(+1)
|
|
44
|
+
when "\x01", "\e[H", "\eOH", "\e[1~" then @cur = 0; render
|
|
45
|
+
when "\x05", "\e[F", "\eOF", "\e[4~" then @cur = @buf.length; render
|
|
46
|
+
when "\eb" then @cur = word_back; render
|
|
47
|
+
when "\ef" then @cur = word_fwd; render
|
|
48
|
+
when "\x10", "\e[A" then history_step(-1)
|
|
49
|
+
when "\x0e", "\e[B" then history_step(+1)
|
|
50
|
+
when "\x7f", "\b", "\x08" then backspace
|
|
51
|
+
when "\x04"
|
|
52
|
+
if @buf.empty? then end_render; print "\r\n"; return nil else forward_delete end
|
|
53
|
+
when "\x0b" then del_range(@cur, @buf.length); render
|
|
54
|
+
when "\x15" then del_range(0, @cur); render
|
|
55
|
+
when "\x17" then j = word_back; del_range(j, @cur); render
|
|
56
|
+
when "\ed" then j = word_fwd; del_range(@cur, j); render
|
|
57
|
+
when "\eu" then case_word(:upcase); render
|
|
58
|
+
when "\eU" then case_word(:downcase); render
|
|
59
|
+
when "\x14" then transpose; render
|
|
60
|
+
when "\x0c" then print "\e[H\e[2J"; @prev_rows = 0; @prev_cur_row = 0; render
|
|
61
|
+
when "\e" then nil
|
|
62
|
+
else insert(k) if printable?(k)
|
|
47
63
|
end
|
|
48
|
-
when "\x0b" then @buf.slice!(@cur..); render
|
|
49
|
-
when "\x15" then @buf.slice!(0...@cur); @cur = 0; render
|
|
50
|
-
when "\x17"
|
|
51
|
-
j = word_back; @buf.slice!(j...@cur); @cur = j; render
|
|
52
|
-
when "\ed"
|
|
53
|
-
j = word_fwd; @buf.slice!(@cur...j); render
|
|
54
|
-
when "\eu" then case_word(:upcase); render
|
|
55
|
-
when "\eU" then case_word(:downcase); render
|
|
56
|
-
when "\x14" then transpose; render
|
|
57
|
-
when "\x0c" then print "\e[H\e[2J"; @prev_rows = 0; @prev_cur_row = 0; render
|
|
58
|
-
when "\e" then nil
|
|
59
|
-
else insert(k) if printable?(k)
|
|
60
64
|
end
|
|
61
65
|
end
|
|
66
|
+
ensure
|
|
67
|
+
print BPASTE_OFF
|
|
62
68
|
end
|
|
63
69
|
end
|
|
64
70
|
|
|
@@ -69,12 +75,13 @@ module AI
|
|
|
69
75
|
def submit
|
|
70
76
|
end_render
|
|
71
77
|
print "\r\n"
|
|
72
|
-
|
|
73
|
-
@
|
|
78
|
+
out = @buf.dup
|
|
79
|
+
@history << out unless out.empty? || out == @history.last
|
|
80
|
+
out
|
|
74
81
|
end
|
|
75
82
|
|
|
76
83
|
def end_render
|
|
77
|
-
down = @prev_rows - 1 - @prev_cur_row
|
|
84
|
+
down = (@prev_rows - 1) - @prev_cur_row
|
|
78
85
|
print "\e[#{down}B" if down > 0
|
|
79
86
|
print "\r"
|
|
80
87
|
end
|
|
@@ -87,38 +94,78 @@ module AI
|
|
|
87
94
|
|
|
88
95
|
def printable?(k) = !k.start_with?("\e") && k.ord >= 32
|
|
89
96
|
|
|
90
|
-
|
|
97
|
+
# ── Buffer mutations (paste-aware) ───────────────────────────────
|
|
98
|
+
|
|
99
|
+
def insert(k)
|
|
100
|
+
@pastes.each { |p| p[:start] += k.length if p[:start] >= @cur }
|
|
101
|
+
@buf.insert(@cur, k)
|
|
102
|
+
@cur += k.length
|
|
103
|
+
render
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def del_range(from, to)
|
|
107
|
+
from = [from, 0].max
|
|
108
|
+
to = [to, @buf.length].min
|
|
109
|
+
return if to <= from
|
|
110
|
+
len = to - from
|
|
111
|
+
@pastes.reject! { |p| p[:start] >= from && p[:start] + p[:length] <= to }
|
|
112
|
+
@pastes.each { |p| p[:start] -= len if p[:start] >= to }
|
|
113
|
+
@buf.slice!(from, len)
|
|
114
|
+
@cur = from if @cur > from && @cur < to
|
|
115
|
+
@cur -= len if @cur >= to
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def backspace
|
|
119
|
+
return if @cur == 0
|
|
120
|
+
paste = @pastes.find { |p| @cur == p[:start] + p[:length] }
|
|
121
|
+
if paste
|
|
122
|
+
del_range(paste[:start], paste[:start] + paste[:length])
|
|
123
|
+
else
|
|
124
|
+
del_range(@cur - 1, @cur)
|
|
125
|
+
end
|
|
126
|
+
render
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def forward_delete
|
|
130
|
+
return if @cur >= @buf.length
|
|
131
|
+
paste = @pastes.find { |p| @cur == p[:start] }
|
|
132
|
+
if paste
|
|
133
|
+
del_range(paste[:start], paste[:start] + paste[:length])
|
|
134
|
+
else
|
|
135
|
+
del_range(@cur, @cur + 1)
|
|
136
|
+
end
|
|
137
|
+
render
|
|
138
|
+
end
|
|
91
139
|
|
|
92
140
|
def move(d)
|
|
93
141
|
nc = @cur + d
|
|
94
|
-
|
|
142
|
+
return unless nc.between?(0, @buf.length)
|
|
143
|
+
paste = @pastes.find { |p| nc > p[:start] && nc < p[:start] + p[:length] }
|
|
144
|
+
nc = (d > 0 ? paste[:start] + paste[:length] : paste[:start]) if paste
|
|
145
|
+
@cur = nc; render
|
|
95
146
|
end
|
|
96
147
|
|
|
148
|
+
# ── Tab completion ───────────────────────────────────────────────
|
|
149
|
+
|
|
97
150
|
def handle_tab
|
|
98
151
|
if @cycle
|
|
99
152
|
@cycle[:index] = (@cycle[:index] + 1) % @cycle[:matches].length
|
|
100
|
-
apply_cycle
|
|
101
|
-
return
|
|
153
|
+
apply_cycle; return
|
|
102
154
|
end
|
|
103
|
-
|
|
104
155
|
return unless @completer
|
|
105
156
|
prefix = @buf[0...@cur]
|
|
106
157
|
result = @completer.call(prefix) or return
|
|
107
158
|
matches, start = result[:matches], result[:start]
|
|
108
159
|
return if matches.nil? || matches.empty?
|
|
109
|
-
|
|
110
160
|
if matches.length == 1
|
|
111
|
-
replace_arg(start, matches[0])
|
|
112
|
-
return
|
|
161
|
+
replace_arg(start, matches[0]); return
|
|
113
162
|
end
|
|
114
|
-
|
|
115
163
|
common = matches.reduce do |a, b|
|
|
116
164
|
i = 0
|
|
117
165
|
i += 1 while i < a.length && i < b.length && a[i] == b[i]
|
|
118
166
|
a[0...i]
|
|
119
167
|
end
|
|
120
168
|
current = prefix[start..]
|
|
121
|
-
|
|
122
169
|
if common.length > current.length
|
|
123
170
|
replace_arg(start, common)
|
|
124
171
|
else
|
|
@@ -142,6 +189,8 @@ module AI
|
|
|
142
189
|
render
|
|
143
190
|
end
|
|
144
191
|
|
|
192
|
+
# ── Input reading ────────────────────────────────────────────────
|
|
193
|
+
|
|
145
194
|
def read_key(io)
|
|
146
195
|
b = io.getbyte or return nil
|
|
147
196
|
return read_escape(io) if b == 27
|
|
@@ -162,12 +211,13 @@ module AI
|
|
|
162
211
|
return "\e\e" unless IO.select([io], nil, nil, 0.05)
|
|
163
212
|
c2 = io.getbyte or return "\e\e"
|
|
164
213
|
c2 == 91 ? read_csi(io, +"\e\e[") : "\e\e#{c2.chr}"
|
|
214
|
+
when 13, 10 then "\e\r"
|
|
165
215
|
else "\e#{c.chr}"
|
|
166
216
|
end
|
|
167
217
|
end
|
|
168
218
|
|
|
169
219
|
def read_csi(io, buf)
|
|
170
|
-
|
|
220
|
+
24.times do
|
|
171
221
|
nb = io.getbyte or break
|
|
172
222
|
buf << nb.chr
|
|
173
223
|
break if nb.between?(64, 126)
|
|
@@ -175,32 +225,120 @@ module AI
|
|
|
175
225
|
buf
|
|
176
226
|
end
|
|
177
227
|
|
|
228
|
+
# ── Bracketed paste collection ───────────────────────────────────
|
|
229
|
+
|
|
230
|
+
def collect_paste(io)
|
|
231
|
+
terminator = "\e[201~".bytes
|
|
232
|
+
raw = +"".b
|
|
233
|
+
window = []
|
|
234
|
+
loop do
|
|
235
|
+
b = io.getbyte or break
|
|
236
|
+
window << b; window.shift if window.length > terminator.length
|
|
237
|
+
raw << b.chr
|
|
238
|
+
break if window == terminator
|
|
239
|
+
end
|
|
240
|
+
raw.slice!(-terminator.length, terminator.length) if raw.bytes.last(terminator.length) == terminator
|
|
241
|
+
text = raw.force_encoding("UTF-8").scrub
|
|
242
|
+
return if text.empty?
|
|
243
|
+
text = text.gsub("\r\n", "\n").tr("\r", "\n")
|
|
244
|
+
start = @cur
|
|
245
|
+
lines = text.count("\n") + 1
|
|
246
|
+
@pastes.each { |p| p[:start] += text.length if p[:start] >= @cur }
|
|
247
|
+
@buf.insert(@cur, text)
|
|
248
|
+
@pastes << { start: start, length: text.length, lines: lines }
|
|
249
|
+
@pastes.sort_by! { |p| p[:start] }
|
|
250
|
+
@cur = start + text.length
|
|
251
|
+
render
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# ── Rendering ────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
def paste_label_for(p)
|
|
257
|
+
n = p[:lines]
|
|
258
|
+
"#{PASTE_STYLE} #{n} #{n == 1 ? "line" : "lines"} pasted #{Ansi.reset}"
|
|
259
|
+
end
|
|
260
|
+
|
|
178
261
|
def render
|
|
179
262
|
cols = (IO.console&.winsize&.last || 80)
|
|
180
263
|
cols = 1 if cols < 1
|
|
264
|
+
|
|
181
265
|
print "\e[#{@prev_cur_row}A" if @prev_cur_row > 0
|
|
182
266
|
print "\r\e[J"
|
|
183
|
-
print "#{@prompt}#{@buf}"
|
|
184
267
|
|
|
185
268
|
prompt_w = Ansi.width(@prompt)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
269
|
+
indent = " " * prompt_w
|
|
270
|
+
|
|
271
|
+
out = +""
|
|
272
|
+
out << @prompt
|
|
273
|
+
|
|
274
|
+
row = 0
|
|
275
|
+
col = prompt_w
|
|
276
|
+
cur_row = 0
|
|
277
|
+
cur_col = prompt_w
|
|
278
|
+
cur_recorded = false
|
|
279
|
+
|
|
280
|
+
advance = lambda do |w|
|
|
281
|
+
col += w
|
|
282
|
+
while col >= cols
|
|
283
|
+
row += 1
|
|
284
|
+
col -= cols
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
newline_to_indent = lambda do
|
|
289
|
+
out << "\r\n" << indent
|
|
290
|
+
row += 1
|
|
291
|
+
col = prompt_w
|
|
194
292
|
end
|
|
195
293
|
|
|
294
|
+
i = 0
|
|
295
|
+
while i <= @buf.length
|
|
296
|
+
if !cur_recorded && i == @cur
|
|
297
|
+
cur_row = row; cur_col = col; cur_recorded = true
|
|
298
|
+
end
|
|
299
|
+
break if i == @buf.length
|
|
300
|
+
|
|
301
|
+
paste = @pastes.find { |p| p[:start] == i }
|
|
302
|
+
if paste
|
|
303
|
+
newline_to_indent.call if col != prompt_w
|
|
304
|
+
label = paste_label_for(paste)
|
|
305
|
+
out << label
|
|
306
|
+
advance.call(Ansi.width(label))
|
|
307
|
+
i += paste[:length]
|
|
308
|
+
newline_to_indent.call
|
|
309
|
+
next
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
c = @buf[i]
|
|
313
|
+
if c == "\n"
|
|
314
|
+
newline_to_indent.call
|
|
315
|
+
i += 1
|
|
316
|
+
next
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
out << c
|
|
320
|
+
advance.call(Ansi.width(c))
|
|
321
|
+
i += 1
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
unless cur_recorded
|
|
325
|
+
cur_row = row; cur_col = col
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
print out
|
|
329
|
+
|
|
330
|
+
end_row = row
|
|
196
331
|
print "\r"
|
|
197
332
|
up = end_row - cur_row
|
|
198
|
-
print "\e[#{up}A"
|
|
333
|
+
print "\e[#{up}A" if up > 0
|
|
199
334
|
print "\e[#{cur_col}C" if cur_col > 0
|
|
200
|
-
|
|
335
|
+
|
|
336
|
+
@prev_rows = end_row + 1
|
|
201
337
|
@prev_cur_row = cur_row
|
|
202
338
|
end
|
|
203
339
|
|
|
340
|
+
# ── History / word ops ───────────────────────────────────────────
|
|
341
|
+
|
|
204
342
|
def history_step(d)
|
|
205
343
|
return if @history.empty?
|
|
206
344
|
@stash = @buf.dup if @hpos == @history.length
|
|
@@ -208,6 +346,7 @@ module AI
|
|
|
208
346
|
return if np == @hpos
|
|
209
347
|
@hpos = np
|
|
210
348
|
@buf = (@hpos == @history.length ? (@stash || +"") : @history[@hpos]).dup
|
|
349
|
+
@pastes = []
|
|
211
350
|
@cur = @buf.length; render
|
|
212
351
|
end
|
|
213
352
|
|