aids 0.1.0 → 1.0.1

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 (6) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ansi.rb +4 -4
  3. data/lib/app.rb +32 -34
  4. data/lib/commands.rb +18 -24
  5. data/lib/line_editor.rb +208 -69
  6. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 337b293a5ba264b783e5dfec4a325a55b64cfd518df952e9d64ab4ce558e06d3
4
- data.tar.gz: 2788dcdd58c52f75802bd09cbbbaa7635f77c3c944822869519a006c125b7217
3
+ metadata.gz: 4602f3e7991c9b5ae3e98e512b153aee77ab8f087d3c825334d3da74fd4f671d
4
+ data.tar.gz: b59f603e9faaf95c9dd6f6cc2a427e86529971f1ebe70bd3128430a2749310df
5
5
  SHA512:
6
- metadata.gz: 4b4505c645ef91a032a84030807383f6754ee2d5775a728a327779f2fd63abc983e18ab5445e71083dafba39bf809f4e44294918a66616941ac3c44bcff02e5e
7
- data.tar.gz: 55ab70c325977de42c7f1e03ff6b511fd4b47bb5c8563c2d95e2d1cb0f6ceba73bcbc28c467c6fa25fb342307f467cb67bdb6b6cb28afa95cb53607c23f6bec0
6
+ metadata.gz: d6c5fb9d07be2e3b2c569794f81cf5524fdf2b4bdfb60db2c26e4418b878db3d1be63dbd600625b97a5810653dc68374dc59cac2a6e3bb999078f367d6b0148a
7
+ data.tar.gz: b5eecfa573d111c185fb4e91d1650499b64bd910c8b678af532ad98bcbb9d3d5a8adebc2a2f92154d06b7724e1b142800bc74f8b9a086429a3e3d57cbb618fc5
data/lib/ansi.rb CHANGED
@@ -3,10 +3,10 @@ module AI
3
3
  module Ansi
4
4
  module_function
5
5
 
6
- def reset = "\e[0m"
7
- def bold = "\e[1m"
8
- def dim = "\e[2m"
9
- def fg(n) = "\e[38;5;#{n}m"
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,7 +4,7 @@ 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 [all | N | A-B | A- | -B]"
7
+ DISCARD_USAGE = "usage: /discard [* | N | A-B | A- | -B]"
8
8
 
9
9
  attr_reader :session, :attachments
10
10
 
@@ -12,8 +12,8 @@ module AI
12
12
  @key = key; @profile = profile
13
13
  @editor = LineEditor.new
14
14
  @editor.completer = ->(prefix) { complete(prefix) }
15
- @hl = Highlighter.new
16
- @fresh = true
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}"
@@ -52,11 +51,10 @@ module AI
52
51
 
53
52
  def discard(arg)
54
53
  arg = arg.to_s.strip
55
- return discard_session if arg == "all"
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 = last.clamp(1, total)
67
+ last = last.clamp(1, total)
70
68
  if last < first
71
69
  notify "no turns in that range"; return
72
70
  end
@@ -104,12 +102,12 @@ module AI
104
102
  src = @session
105
103
  dup = Session.new
106
104
  dup.messages = Marshal.load(Marshal.dump(src.messages))
107
- dup.usages = Marshal.load(Marshal.dump(src.usages))
108
- dup.title = src.title ? "#{src.title} (clone)" : "(clone)"
105
+ dup.usages = Marshal.load(Marshal.dump(src.usages))
106
+ dup.title = src.title ? "#{src.title} (clone)" : "(clone)"
109
107
  dup.save(@profile)
110
108
  FileUtils.cp(src.history_file, dup.history_file) if File.exist?(src.history_file)
111
109
  @session = dup
112
- @fresh = false
110
+ @fresh = false
113
111
  load_history
114
112
  redraw
115
113
  notify "cloned to new session"
@@ -155,19 +153,19 @@ module AI
155
153
  end
156
154
  expanded = File.expand_path(arg)
157
155
  targets = if File.directory?(expanded)
158
- @attachments.select { |p| File.dirname(p) == expanded }
159
- else
160
- exact = @attachments.find { |p| Paths.short(p) == arg }
161
- if exact
162
- [exact]
156
+ @attachments.select { |p| File.dirname(p) == expanded }
163
157
  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
- }
158
+ exact = @attachments.find { |p| Paths.short(p) == arg }
159
+ if exact
160
+ [exact]
161
+ else
162
+ @attachments.select { |p|
163
+ File.fnmatch(arg, File.basename(p)) ||
164
+ File.fnmatch(arg, Paths.short(p)) ||
165
+ File.fnmatch(arg, p)
166
+ }
167
+ end
169
168
  end
170
- end
171
169
  if targets.empty?
172
170
  notify "no matching attachments: #{arg}"
173
171
  return
@@ -187,12 +185,12 @@ module AI
187
185
  redraw if @session.messages.any?
188
186
  loop do
189
187
  puts format_attachment_summary if @attachments.any?
190
- result = begin; @editor.readline(build_prompt); rescue Interrupt; break; end
188
+ result = begin; @editor.readline(build_prompt); rescue Interrupt; break; end
191
189
  case result
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
190
+ when nil then break
191
+ when :sess_next then switch_session(+1)
192
+ when :sess_prev then switch_session(-1)
193
+ when :redraw then redraw
196
194
  when :del_session then delete_session
197
195
  when String
198
196
  t = result.strip
@@ -251,10 +249,10 @@ module AI
251
249
  def complete(prefix)
252
250
  return nil unless Commands.slash?(prefix)
253
251
  if (m = prefix.match(/\A(\/\S+)\s+/))
254
- name = m[1]
252
+ name = m[1]
255
253
  arg_start = m.end(0)
256
- arg = prefix[arg_start..]
257
- cmd = Commands.find(name)
254
+ arg = prefix[arg_start..]
255
+ cmd = Commands.find(name)
258
256
  return nil unless cmd && cmd.available?(self) && cmd.arg_complete
259
257
  { matches: cmd.arg_complete.call(self, arg), start: arg_start }
260
258
  else
@@ -264,7 +262,7 @@ module AI
264
262
 
265
263
  def handle_command(input)
266
264
  case Commands.dispatch(self, input)
267
- when :unknown then notify "unknown command: #{input.split.first}"
265
+ when :unknown then notify "unknown command: #{input.split.first}"
268
266
  when :unavailable then notify "not available right now: #{input.split.first}"
269
267
  end
270
268
  end
@@ -279,7 +277,7 @@ module AI
279
277
 
280
278
  def center(str)
281
279
  cols = IO.console&.winsize&.last || 80
282
- pad = [(cols - Ansi.width(str)) / 2, 0].max
280
+ pad = [(cols - Ansi.width(str)) / 2, 0].max
283
281
  (" " * pad) + str
284
282
  end
285
283
 
@@ -368,7 +366,7 @@ module AI
368
366
  def draw_meta
369
367
  all = Session.all
370
368
  idx = all.index(@session.stamp)
371
- n = idx ? idx + 1 : all.length + 1
369
+ n = idx ? idx + 1 : all.length + 1
372
370
  total = [all.length, n].max
373
371
  puts center("#{CMT_STYLE}session #{n}/#{total} \u00b7 #{@session.human_time}#{Ansi.reset}")
374
372
  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) = REGISTRY[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
- desc: "Remove turn(s): /discard [all|N|A-B|A-|-B]",
37
- available: ->(app) { !app.session.empty? },
38
- arg_complete: ->(_app, arg) { %w[all].select { |x| x.start_with?(arg) } }
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
- desc: "Set the session title",
45
- available: ->(app) { !app.session.empty? }
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
- desc: "Clone this session into a new active one",
52
- available: ->(app) { !app.session.empty? }
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
- desc: "Attach a file or directory to the next turn",
59
- arg_complete: ->(_app, arg) { Paths.complete(arg) }
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
- desc: "Remove an attachment",
66
- available: ->(app) { app.attachments.any? },
67
- arg_complete: ->(app, arg) {
68
- app.attachments.map { |p| Paths.short(p) }.select { |s| s.start_with?(arg) }
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
- desc: "List currently attached files",
76
- available: ->(app) { app.attachments.any? }
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 (with cycling) ────
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" => :sess_next,
8
- "\ek" => :sess_prev,
9
- "\el" => :redraw, "\eL" => :redraw,
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^" => :del_session,
12
- "\e[3^" => :del_session, "\e[3;8~" => :del_session
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 = 0; @prev_cur_row = 0
24
+ @prev_rows = 1; @prev_cur_row = 0
21
25
  @cycle = nil
26
+ @pastes = []
27
+ print BPASTE_ON
22
28
  render
23
- $stdin.raw do |io|
24
- loop do
25
- k = read_key(io) or next
26
- if (sig = SIGNALS[k]) then return signal(sig) end
27
- @cycle = nil unless k == "\t"
28
- case k
29
- when "\r", "\n" then return submit
30
- when "\t" then handle_tab
31
- when "\x03" then end_render; print "\r\n"; raise Interrupt
32
- when "\e[3~"
33
- @buf.slice!(@cur); render if @cur < @buf.length
34
- when "\x02", "\e[D" then move(-1)
35
- when "\x06", "\e[C" then move(+1)
36
- when "\x01", "\e[H", "\eOH", "\e[1~" then @cur = 0; render
37
- when "\x05", "\e[F", "\eOF", "\e[4~" then @cur = @buf.length; render
38
- when "\eb" then @cur = word_back; render
39
- when "\ef" then @cur = word_fwd; render
40
- when "\x10", "\e[A" then history_step(-1)
41
- when "\x0e", "\e[B" then history_step(+1)
42
- when "\x7f", "\b", "\x08"
43
- (@buf.slice!(@cur - 1); @cur -= 1; render) if @cur > 0
44
- when "\x04"
45
- if @buf.empty? then end_render; print "\r\n"; return nil
46
- elsif @cur < @buf.length then @buf.slice!(@cur); render
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
- @history << @buf.dup unless @buf.empty? || @buf == @history.last
73
- @buf
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
- def insert(k); @buf.insert(@cur, k); @cur += k.length; render; end
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
- (@cur = nc; render) if nc.between?(0, @buf.length)
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
- 12.times do
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
- total_w = prompt_w + Ansi.width(@buf)
187
- cur_w = prompt_w + Ansi.width(@buf[0...@cur] || "")
188
- total_rows = total_w.zero? ? 1 : ((total_w - 1) / cols) + 1
189
- end_row = total_rows - 1
190
- cur_row = cur_w / cols
191
- cur_col = cur_w % cols
192
- if cur_w.positive? && cur_w == total_w && cur_w % cols == 0
193
- cur_row -= 1; cur_col = cols
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" if up > 0
333
+ print "\e[#{up}A" if up > 0
199
334
  print "\e[#{cur_col}C" if cur_col > 0
200
- @prev_rows = total_rows
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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aids
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - emanrdesu