kward 0.73.0 → 0.74.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.
@@ -5,7 +5,7 @@ module Kward
5
5
  # Vibe-style keymap for the built-in composer file editor.
6
6
  module VibeEditorMode
7
7
  VIBE_SIMPLE_MOTION_KEYS = [
8
- "w", "e", "b", "$", "0", "^", "+", "\n", "\r", "-", "_",
8
+ "w", "e", "b", "W", "E", "B", "$", "0", "^", "|", "+", "\n", "\r", "-", "_",
9
9
  "h", "\b", "\x7F", "j", "k", "l", " ", "{", "}"
10
10
  ].freeze
11
11
  VIBE_PAIR_TEXT_OBJECTS = {
@@ -38,6 +38,8 @@ module Kward
38
38
  csi_result = handle_vibe_csi_u_key(key)
39
39
  return csi_result unless csi_result == false
40
40
 
41
+ return handle_vibe_command_key(key) if @editor_state.vibe_mode == "command"
42
+
41
43
  tab_result = handle_tab_key_binding(key)
42
44
  return tab_result unless tab_result == false
43
45
 
@@ -46,7 +48,6 @@ module Kward
46
48
  return vibe_begin_visual_mode("visual_block") if key == TerminalKeys::CTRL_V && @editor_state.vibe_mode == "normal"
47
49
  return handle_vibe_repeat_change if key == "." && @editor_state.vibe_mode == "normal"
48
50
  return handle_vibe_search_key(key) if editor_search_active?
49
- return handle_vibe_command_key(key) if @editor_state.vibe_mode == "command"
50
51
  return handle_vibe_insert_key(key) if @editor_state.vibe_mode == "insert"
51
52
  return handle_vibe_replace_key(key) if @editor_state.vibe_mode == "replace"
52
53
  return handle_vibe_visual_key(key) if vibe_visual_mode?
@@ -57,6 +58,10 @@ module Kward
57
58
  def handle_vibe_csi_u_key(key)
58
59
  sequence = parse_csi_u_key(key)
59
60
  return false unless sequence
61
+ if editor_search_active?
62
+ search_result = handle_editor_search_csi_u_key(sequence)
63
+ return search_result unless search_result == false
64
+ end
60
65
 
61
66
  code = sequence[:code]
62
67
  modifier = sequence[:modifier]
@@ -91,8 +96,12 @@ module Kward
91
96
  @editor_state.move_indentation_down
92
97
  when 107
93
98
  @editor_state.move_indentation_up
99
+ when 105
100
+ vibe_jump_forward
94
101
  when 108
95
102
  @editor_state.move_line_end
103
+ when 111
104
+ vibe_jump_backward
96
105
  when 118
97
106
  vibe_begin_visual_mode("visual_block")
98
107
  else
@@ -104,6 +113,7 @@ module Kward
104
113
  code = sequence[:code]
105
114
  text = csi_u_text(sequence)
106
115
  normalized_code = code.to_i.chr.downcase.ord rescue code
116
+ return "\t" if code == 9
107
117
  return "\n" if code == 13
108
118
  return "\x7F" if [8, 127].include?(code)
109
119
  return (normalized_code - 96).chr if ctrl_modifier?(sequence[:modifier]) && normalized_code.between?(97, 122)
@@ -189,6 +199,8 @@ module Kward
189
199
  when "\b", "\x7F"
190
200
  @editor_state.vibe_command = @editor_state.vibe_command[0...-1].to_s
191
201
  @editor_state.status = ":#{@editor_state.vibe_command}"
202
+ when "\t"
203
+ vibe_complete_command_path
192
204
  when "\n", "\r"
193
205
  execute_vibe_command(@editor_state.vibe_command)
194
206
  else
@@ -209,6 +221,8 @@ module Kward
209
221
  save_editor
210
222
  when /\Aw\s+(.+)\z/
211
223
  save_editor(Regexp.last_match(1))
224
+ when /\Ae(!?)\s+(.+)\z/
225
+ vibe_edit_file(Regexp.last_match(2), force: Regexp.last_match(1) == "!")
212
226
  when "run"
213
227
  vibe_record_undo { run_editor_buffer }
214
228
  when "q"
@@ -233,6 +247,81 @@ module Kward
233
247
  true
234
248
  end
235
249
 
250
+ def vibe_edit_file(path, force: false)
251
+ if @editor_state.dirty? && !force
252
+ @editor_state.status = "No write since last change (:e! overrides)"
253
+ return true
254
+ end
255
+
256
+ open_editor(path, allow_new: true)
257
+ end
258
+
259
+ def vibe_complete_command_path
260
+ command = @editor_state.vibe_command.to_s
261
+ match = command.match(/\A(e!?)\s+(.*)\z/)
262
+ return false unless match
263
+
264
+ prefix = match[2]
265
+ candidates = vibe_path_completion_candidates(prefix)
266
+ if candidates.empty?
267
+ @editor_state.status = "No matches"
268
+ return true
269
+ end
270
+
271
+ replacement = candidates.length == 1 ? candidates.first : vibe_common_prefix(candidates)
272
+ if replacement.length > prefix.length
273
+ @editor_state.vibe_command = "#{match[1]} #{replacement}"
274
+ @editor_state.status = ":#{@editor_state.vibe_command}"
275
+ elsif candidates.length > 1
276
+ @editor_state.status = vibe_path_completion_status(candidates)
277
+ end
278
+ true
279
+ end
280
+
281
+ def vibe_path_completion_candidates(prefix)
282
+ directory_prefix, basename_prefix = vibe_split_path_completion_prefix(prefix)
283
+ search_directory = File.expand_path(directory_prefix.empty? ? "." : directory_prefix, Dir.pwd)
284
+ root = File.expand_path(Dir.pwd)
285
+ return [] unless search_directory == root || search_directory.start_with?("#{root}/")
286
+ return [] unless File.directory?(search_directory)
287
+
288
+ Dir.children(search_directory).sort.filter_map do |entry|
289
+ next if entry.start_with?(".") && !basename_prefix.start_with?(".")
290
+ next unless entry.start_with?(basename_prefix)
291
+
292
+ path = File.join(search_directory, entry)
293
+ candidate = "#{directory_prefix}#{entry}"
294
+ File.directory?(path) ? "#{candidate}/" : candidate
295
+ end
296
+ rescue StandardError
297
+ []
298
+ end
299
+
300
+ def vibe_split_path_completion_prefix(prefix)
301
+ if prefix.include?(File::SEPARATOR)
302
+ directory = prefix[0..prefix.rindex(File::SEPARATOR)].to_s
303
+ basename = prefix[(prefix.rindex(File::SEPARATOR) + 1)..].to_s
304
+ [directory, basename]
305
+ else
306
+ ["", prefix]
307
+ end
308
+ end
309
+
310
+ def vibe_common_prefix(values)
311
+ return "" if values.empty?
312
+
313
+ values.reduce(values.first.dup) do |prefix, value|
314
+ prefix = prefix[0...-1] until value.start_with?(prefix) || prefix.empty?
315
+ prefix
316
+ end
317
+ end
318
+
319
+ def vibe_path_completion_status(candidates)
320
+ visible = candidates.first(6)
321
+ suffix = candidates.length > visible.length ? " …" : ""
322
+ "#{candidates.length} matches: #{visible.join(" ")}#{suffix}"
323
+ end
324
+
236
325
  def vibe_substitute_command(range, pattern, replacement, global: false)
237
326
  if pattern.empty?
238
327
  @editor_state.status = "Substitute pattern required"
@@ -327,7 +416,7 @@ module Kward
327
416
  end
328
417
 
329
418
  def vibe_normal_control_key?(key)
330
- ["\n", "\r", "\b", "\x7F", TerminalKeys::CTRL_B, TerminalKeys::CTRL_D, TerminalKeys::CTRL_E, TerminalKeys::CTRL_F, TerminalKeys::CTRL_R, TerminalKeys::CTRL_U, TerminalKeys::CTRL_Y].include?(key)
419
+ ["\n", "\r", "\b", "\x7F", TerminalKeys::CTRL_B, TerminalKeys::CTRL_D, TerminalKeys::CTRL_E, TerminalKeys::CTRL_F, "\x0F", TerminalKeys::CTRL_R, TerminalKeys::CTRL_U, TerminalKeys::CTRL_Y].include?(key)
331
420
  end
332
421
 
333
422
  def vibe_visual_mode?
@@ -361,6 +450,7 @@ module Kward
361
450
  def vibe_waiting_for_more?(command)
362
451
  return true if command.match?(/\A\d+\z/) && command != "0"
363
452
  return true if command.match?(/\A\d*g\z/)
453
+ return true if command.match?(/\A\d*g[uU~]\z/)
364
454
  return true if command.match?(/\A\d*z\z/)
365
455
  return true if command.match?(/\A\d*[cdy]\d*\z/)
366
456
  return true if command.match?(/\A\d*[cdy]\d*[ai]\z/)
@@ -374,6 +464,7 @@ module Kward
374
464
  return true if command.match?(/\Aq\z/)
375
465
  return true if command.match?(/\A@\z/)
376
466
  return true if command.match?(/\A[\[\]]\z/)
467
+ return true if command.match?(/\A\d*[<>]\z/)
377
468
  return true if command.match?(/\A['`]\z/)
378
469
 
379
470
  false
@@ -393,9 +484,30 @@ module Kward
393
484
  when *VIBE_SIMPLE_MOTION_KEYS
394
485
  vibe_apply_cursor_motion(body, count)
395
486
  when "gg"
487
+ vibe_record_jump
396
488
  @editor_state.move_file_start
489
+ when "g_"
490
+ vibe_move_line_last_non_blank
491
+ when "gJ"
492
+ vibe_join_lines(count, command, separator: "")
493
+ when "ge"
494
+ count.times { vibe_move_to_previous_word_end }
495
+ when "gE"
496
+ count.times { vibe_move_to_previous_big_word_end }
497
+ when "guu"
498
+ vibe_transform_lines(count, :downcase, command)
499
+ when "gUU"
500
+ vibe_transform_lines(count, :upcase, command)
501
+ when "g~~"
502
+ vibe_transform_lines(count, :swapcase, command)
503
+ when /\Ag([uU~])(.+)\z/
504
+ vibe_transform_operator(Regexp.last_match(1), Regexp.last_match(2), count, command)
397
505
  when "gv"
398
506
  vibe_restore_visual_selection
507
+ when "`."
508
+ vibe_jump_to_previous_change(linewise: false)
509
+ when "'."
510
+ vibe_jump_to_previous_change(linewise: true)
399
511
  when "]m"
400
512
  vibe_jump_ruby_method(:forward)
401
513
  when "[m"
@@ -413,6 +525,7 @@ module Kward
413
525
  when /\A`(.+)\z/
414
526
  vibe_jump_to_mark(Regexp.last_match(1), linewise: false)
415
527
  when "G"
528
+ vibe_record_jump
416
529
  line = command.match?(/\A\d+G\z/) ? count - 1 : @editor_state.lines.length - 1
417
530
  @editor_state.set_cursor_line_and_column(line, 0)
418
531
  when "zz"
@@ -439,6 +552,8 @@ module Kward
439
552
  vibe_scroll_down
440
553
  when TerminalKeys::CTRL_Y
441
554
  vibe_scroll_up
555
+ when "\x0F"
556
+ vibe_jump_backward
442
557
  when TerminalKeys::CTRL_R
443
558
  @editor_state.redo
444
559
  when "i"
@@ -466,6 +581,8 @@ module Kward
466
581
  vibe_change_lines(count, command)
467
582
  when "J"
468
583
  vibe_join_lines(count, command)
584
+ when "~"
585
+ vibe_swapcase_characters(count, command)
469
586
  when "n"
470
587
  editor_search_repeat
471
588
  when "N"
@@ -477,7 +594,8 @@ module Kward
477
594
  when "U"
478
595
  vibe_restore_current_line
479
596
  when "%"
480
- vibe_jump_to_matching_pair
597
+ vibe_record_jump
598
+ command.match?(/\A\d+%\z/) ? vibe_jump_to_file_percentage(count) : vibe_jump_to_matching_pair
481
599
  when /^([fFtT])(.?)$/
482
600
  vibe_find_character(Regexp.last_match(1), Regexp.last_match(2), count)
483
601
  when ";"
@@ -495,11 +613,9 @@ module Kward
495
613
  when "O"
496
614
  vibe_open_line_above
497
615
  when "x"
498
- vibe_record_undo { count.times { @editor_state.delete_at_cursor } }
499
- vibe_remember_change(command)
616
+ vibe_delete_characters(count, command)
500
617
  when "X"
501
- vibe_record_undo { count.times { @editor_state.delete_before_cursor } }
502
- vibe_remember_change(command)
618
+ vibe_delete_characters_before_cursor(count, command)
503
619
  when "dd"
504
620
  vibe_delete_lines(count)
505
621
  vibe_store_active_register
@@ -510,9 +626,17 @@ module Kward
510
626
  when "yy"
511
627
  vibe_yank_lines(count)
512
628
  vibe_store_active_register
629
+ when "Y"
630
+ vibe_yank_lines(count)
631
+ vibe_store_active_register
632
+ when ">>"
633
+ vibe_indent_lines(count, :right)
634
+ vibe_remember_change(command)
635
+ when "<<"
636
+ vibe_indent_lines(count, :left)
637
+ vibe_remember_change(command)
513
638
  when "p"
514
- vibe_record_undo { @editor_state.insert(vibe_active_register_text) }
515
- vibe_remember_change(original_command)
639
+ vibe_active_register_linewise? ? vibe_paste_line(:below, original_command) : vibe_paste_after(original_command)
516
640
  when "P"
517
641
  vibe_paste_before(original_command)
518
642
  when "u"
@@ -709,12 +833,56 @@ module Kward
709
833
  @vibe_replaying_macro = false
710
834
  end
711
835
 
836
+ def vibe_record_jump
837
+ current = @editor_state.cursor
838
+ return if @editor_state.vibe_jump_back_list.last == current
839
+
840
+ @editor_state.vibe_jump_back_list << current
841
+ @editor_state.vibe_jump_forward_list.clear
842
+ end
843
+
844
+ def vibe_jump_backward
845
+ target = @editor_state.vibe_jump_back_list.pop
846
+ unless target
847
+ @editor_state.status = "Already at oldest jump"
848
+ return false
849
+ end
850
+
851
+ @editor_state.vibe_jump_forward_list << @editor_state.cursor
852
+ @editor_state.cursor = [[target, 0].max, @editor_state.buffer.length].min
853
+ true
854
+ end
855
+
856
+ def vibe_jump_forward
857
+ target = @editor_state.vibe_jump_forward_list.pop
858
+ unless target
859
+ @editor_state.status = "Already at newest jump"
860
+ return false
861
+ end
862
+
863
+ @editor_state.vibe_jump_back_list << @editor_state.cursor
864
+ @editor_state.cursor = [[target, 0].max, @editor_state.buffer.length].min
865
+ true
866
+ end
867
+
712
868
  def vibe_set_mark(name)
713
869
  @editor_state.vibe_marks[name] = { cursor: @editor_state.cursor }
714
870
  @editor_state.status = "Set mark #{name}"
715
871
  true
716
872
  end
717
873
 
874
+ def vibe_jump_to_previous_change(linewise:)
875
+ cursor = @editor_state.vibe_previous_change_cursor
876
+ unless cursor
877
+ @editor_state.status = "Previous change not set"
878
+ return false
879
+ end
880
+
881
+ @editor_state.cursor = [[cursor, 0].max, @editor_state.buffer.length].min
882
+ @editor_state.move_line_first_non_blank if linewise
883
+ true
884
+ end
885
+
718
886
  def vibe_jump_to_mark(name, linewise:)
719
887
  mark = @editor_state.vibe_marks[name]
720
888
  unless mark
@@ -722,6 +890,7 @@ module Kward
722
890
  return false
723
891
  end
724
892
 
893
+ vibe_record_jump
725
894
  @editor_state.cursor = [[mark[:cursor], 0].max, @editor_state.buffer.length].min
726
895
  @editor_state.move_line_first_non_blank if linewise
727
896
  true
@@ -920,6 +1089,14 @@ module Kward
920
1089
  @editor_state.move_to_line_first_non_blank(line + offset)
921
1090
  end
922
1091
 
1092
+ def vibe_move_line_last_non_blank
1093
+ line, = @editor_state.cursor_line_and_column
1094
+ text = @editor_state.lines[line].to_s
1095
+ column = text.rindex(/\S/) || 0
1096
+ @editor_state.set_cursor_line_and_column(line, column)
1097
+ true
1098
+ end
1099
+
923
1100
  def vibe_move_to_screen_line(offset)
924
1101
  target_row = @editor_state.viewport_row + offset
925
1102
  if current_editor_soft_wrap?
@@ -996,14 +1173,72 @@ module Kward
996
1173
  @editor_state.status = "INSERT · Esc normal"
997
1174
  end
998
1175
 
1176
+ def vibe_delete_characters(count, command = nil)
1177
+ start_index = @editor_state.cursor
1178
+ end_index = [start_index + count, @editor_state.buffer.length].min
1179
+ return @editor_state.status = "Empty range" if start_index == end_index
1180
+
1181
+ @editor_state.copy_range(start_index, end_index)
1182
+ @editor_state.vibe_kill_linewise = false
1183
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, "") }
1184
+ @vibe_character_delete_for_paste = @vibe_active_register.nil?
1185
+ vibe_store_active_register
1186
+ vibe_remember_change(command)
1187
+ end
1188
+
1189
+ def vibe_delete_characters_before_cursor(count, command = nil)
1190
+ end_index = @editor_state.cursor
1191
+ start_index = [end_index - count, 0].max
1192
+ return @editor_state.status = "Empty range" if start_index == end_index
1193
+
1194
+ @editor_state.copy_range(start_index, end_index)
1195
+ @editor_state.vibe_kill_linewise = false
1196
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, "") }
1197
+ vibe_store_active_register
1198
+ vibe_remember_change(command)
1199
+ end
1200
+
1201
+ def vibe_paste_after(command = nil)
1202
+ return vibe_paste_line(:below, command) if vibe_active_register_linewise?
1203
+
1204
+ text = vibe_active_register_text
1205
+ return false if text.empty?
1206
+
1207
+ vibe_record_undo do
1208
+ @editor_state.cursor = [@editor_state.cursor + 1, @editor_state.buffer.length].min if @vibe_character_delete_for_paste
1209
+ @editor_state.insert(text)
1210
+ end
1211
+ @vibe_character_delete_for_paste = false
1212
+ vibe_remember_change(command)
1213
+ end
1214
+
999
1215
  def vibe_paste_before(command = nil)
1216
+ return vibe_paste_line(:above, command) if vibe_active_register_linewise?
1217
+
1000
1218
  text = vibe_active_register_text
1001
1219
  return false if text.empty?
1002
1220
 
1221
+ vibe_record_undo { @editor_state.insert(text) }
1222
+ vibe_remember_change(command)
1223
+ end
1224
+
1225
+ def vibe_paste_line(position, command = nil)
1226
+ text = vibe_active_register_text
1227
+ return false if text.empty?
1228
+
1229
+ text += "\n" unless text.end_with?("\n")
1230
+ line, = @editor_state.cursor_line_and_column
1231
+ target_line = position == :below ? line + 1 : line
1232
+ insert_index = if target_line >= @editor_state.lines.length
1233
+ @editor_state.buffer.end_with?("\n") ? @editor_state.buffer.length : @editor_state.line_range(line)[0]
1234
+ else
1235
+ @editor_state.line_range(target_line)[0]
1236
+ end
1003
1237
  vibe_record_undo do
1004
- @editor_state.cursor = @editor_state.current_line_range.first if text.end_with?("\n")
1238
+ @editor_state.cursor = insert_index
1005
1239
  @editor_state.insert(text)
1006
1240
  end
1241
+ @editor_state.set_cursor_line_and_column(target_line, 0)
1007
1242
  vibe_remember_change(command)
1008
1243
  end
1009
1244
 
@@ -1030,12 +1265,29 @@ module Kward
1030
1265
  start_index, = @editor_state.line_range(line)
1031
1266
  end_line = [line + count - 1, @editor_state.lines.length - 1].min
1032
1267
  _, end_index = @editor_state.line_range(end_line)
1033
- vibe_copy_range(start_index, end_index, "Yanked #{count} line#{count == 1 ? "" : "s"}")
1268
+ vibe_copy_range(start_index, end_index, "Yanked #{count} line#{count == 1 ? "" : "s"}", linewise: true)
1269
+ end
1270
+
1271
+ def vibe_indent_lines(count, direction)
1272
+ line, = @editor_state.cursor_line_and_column
1273
+ end_line = [line + count - 1, @editor_state.lines.length - 1].min
1274
+ start_index = @editor_state.line_range(line)[0]
1275
+ end_index = @editor_state.line_range(end_line)[1]
1276
+ original_text = @editor_state.buffer[start_index...end_index].to_s
1277
+ lines = @editor_state.lines[line..end_line].map do |source|
1278
+ direction == :right ? " #{source}" : source.sub(/\A(?: |\t| )/, "")
1279
+ end
1280
+ replacement = lines.join("\n")
1281
+ replacement += "\n" if original_text.end_with?("\n")
1282
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, replacement) }
1283
+ @editor_state.set_cursor_line_and_column(line, 0)
1284
+ true
1034
1285
  end
1035
1286
 
1036
1287
  def vibe_change_lines(count, command = nil)
1037
1288
  start_index, end_index = vibe_linewise_change_range(count)
1038
1289
  @editor_state.copy_range(start_index, end_index)
1290
+ @editor_state.vibe_kill_linewise = true
1039
1291
  vibe_record_undo { @editor_state.replace_range(start_index, end_index, "") }
1040
1292
  @editor_state.cursor = start_index
1041
1293
  vibe_enter_insert_mode(command)
@@ -1080,6 +1332,17 @@ module Kward
1080
1332
  vibe_enter_insert_mode(command)
1081
1333
  end
1082
1334
 
1335
+ def vibe_swapcase_characters(count, command = nil)
1336
+ start_index = @editor_state.cursor
1337
+ return @editor_state.status = "Empty range" if start_index >= @editor_state.buffer.length
1338
+
1339
+ end_index = [start_index + count, @editor_state.buffer.length].min
1340
+ text = @editor_state.buffer[start_index...end_index].to_s.swapcase
1341
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, text) }
1342
+ @editor_state.cursor = [end_index, @editor_state.buffer.length].min
1343
+ vibe_remember_change(command)
1344
+ end
1345
+
1083
1346
  def vibe_replace_single_character(character, count, command = nil)
1084
1347
  return @editor_state.status = "Replacement character required" if character.to_s.empty?
1085
1348
 
@@ -1093,7 +1356,7 @@ module Kward
1093
1356
  vibe_remember_change(command)
1094
1357
  end
1095
1358
 
1096
- def vibe_join_lines(count, command = nil)
1359
+ def vibe_join_lines(count, command = nil, separator: nil)
1097
1360
  line, = @editor_state.cursor_line_and_column
1098
1361
  join_count = [count, 2].max
1099
1362
  end_line = [line + join_count - 1, @editor_state.lines.length - 1].min
@@ -1105,8 +1368,8 @@ module Kward
1105
1368
  next_line_start = line_end + 1
1106
1369
  next_line_end = next_line_start + @editor_state.lines[line + 1].to_s.length
1107
1370
  next_line = @editor_state.buffer[next_line_start...next_line_end].to_s.sub(/\A\s+/, "")
1108
- separator = next_line.empty? ? "" : " "
1109
- @editor_state.replace_range(line_end, next_line_end, separator + next_line)
1371
+ join_separator = separator.nil? ? (next_line.empty? ? "" : " ") : separator
1372
+ @editor_state.replace_range(line_end, next_line_end, join_separator + next_line)
1110
1373
  @editor_state.cursor = line_end
1111
1374
  end
1112
1375
  end
@@ -1119,8 +1382,42 @@ module Kward
1119
1382
  vibe_begin_change_recording(command) if command
1120
1383
  end
1121
1384
 
1385
+ def vibe_transform_operator(transform_key, motion, count, command = nil)
1386
+ motion_count, motion = vibe_count_and_body(motion)
1387
+ count *= motion_count if motion_count.positive?
1388
+ target = vibe_operator_target(motion, count)
1389
+ return false unless target
1390
+ return @editor_state.status = "Empty range" if target.start_index == target.end_index
1391
+
1392
+ transform = { "u" => :downcase, "U" => :upcase, "~" => :swapcase }.fetch(transform_key)
1393
+ vibe_transform_range(target.start_index, target.end_index, transform)
1394
+ vibe_remember_change(command)
1395
+ end
1396
+
1397
+ def vibe_transform_lines(count, transform, command = nil)
1398
+ line, = @editor_state.cursor_line_and_column
1399
+ end_line = [line + count - 1, @editor_state.lines.length - 1].min
1400
+ start_index = @editor_state.line_range(line)[0]
1401
+ end_index = @editor_state.line_range(end_line)[1]
1402
+ vibe_transform_range(start_index, end_index, transform)
1403
+ @editor_state.set_cursor_line_and_column(line, 0)
1404
+ vibe_remember_change(command)
1405
+ end
1406
+
1407
+ def vibe_transform_range(start_index, end_index, transform)
1408
+ text = @editor_state.buffer[start_index...end_index].to_s
1409
+ replacement = case transform
1410
+ when :swapcase then text.swapcase
1411
+ when :downcase then text.downcase
1412
+ else text.upcase
1413
+ end
1414
+ vibe_record_undo { @editor_state.replace_range(start_index, end_index, replacement) }
1415
+ end
1416
+
1122
1417
  def vibe_operator_motion(operator, motion, count, command = nil)
1123
1418
  motion_count, motion = vibe_count_and_body(motion)
1419
+ return vibe_operator_linewise_motion(operator, motion_count, command) if motion == "G"
1420
+
1124
1421
  count *= motion_count if motion_count.positive?
1125
1422
  return vibe_operator_linewise(operator, count, command) if motion == operator
1126
1423
 
@@ -1131,28 +1428,49 @@ module Kward
1131
1428
  vibe_apply_operator_to_target(operator, target, command, motion, count, motion_count)
1132
1429
  end
1133
1430
 
1431
+ def vibe_operator_linewise_motion(operator, line_count, command = nil)
1432
+ line, = @editor_state.cursor_line_and_column
1433
+ target_line = line_count.positive? ? line_count - 1 : @editor_state.lines.length - 1
1434
+ start_line, end_line = [line, target_line].minmax
1435
+ start_index, = @editor_state.line_range(start_line)
1436
+ _, end_index = @editor_state.line_range(end_line)
1437
+ start_index -= 1 if end_index == @editor_state.buffer.length && start_index.positive?
1438
+
1439
+ target = VibeOperatorTarget.new(type: :linewise, start_index: start_index, end_index: end_index)
1440
+ vibe_apply_operator_to_target(operator, target, command, "G", 1, line_count)
1441
+ end
1442
+
1134
1443
  def vibe_active_register_text
1135
1444
  return @editor_state.vibe_registers[@vibe_active_register].to_s if @vibe_active_register
1136
1445
 
1137
1446
  @editor_state.kill_buffer.to_s
1138
1447
  end
1139
1448
 
1140
- def vibe_store_active_register
1449
+ def vibe_active_register_linewise?
1450
+ return @editor_state.vibe_register_types[@vibe_active_register] == :linewise if @vibe_active_register
1451
+
1452
+ @editor_state.vibe_kill_linewise || @editor_state.kill_buffer.to_s.end_with?("\n")
1453
+ end
1454
+
1455
+ def vibe_store_active_register(linewise: @editor_state.vibe_kill_linewise)
1141
1456
  return unless @vibe_active_register
1142
1457
 
1143
1458
  @editor_state.vibe_registers[@vibe_active_register] = @editor_state.kill_buffer.to_s
1459
+ @editor_state.vibe_register_types[@vibe_active_register] = linewise ? :linewise : :characterwise
1144
1460
  end
1145
1461
 
1146
1462
  def vibe_apply_operator_to_target(operator, target, command, motion, count, motion_count)
1147
1463
  case operator
1148
1464
  when "d"
1149
1465
  @editor_state.copy_range(target.start_index, target.end_index)
1466
+ @editor_state.vibe_kill_linewise = target.type == :linewise
1150
1467
  vibe_record_undo { @editor_state.replace_range(target.start_index, target.end_index, "") }
1151
1468
  @editor_state.status = "Deleted"
1152
1469
  vibe_store_active_register
1153
1470
  vibe_remember_change(command)
1154
1471
  when "c"
1155
1472
  @editor_state.copy_range(target.start_index, target.end_index)
1473
+ @editor_state.vibe_kill_linewise = target.type == :linewise
1156
1474
  vibe_record_undo do
1157
1475
  @editor_state.replace_range(target.start_index, target.end_index, target.change_replacement_text)
1158
1476
  @editor_state.cursor = target.change_cursor_index
@@ -1168,7 +1486,7 @@ module Kward
1168
1486
 
1169
1487
  def vibe_operator_target(motion, count)
1170
1488
  return vibe_text_object_target(motion) if motion.match?(/\A[ai].\z/)
1171
- return vibe_word_motion_target(motion, count) if %w[w e b].include?(motion)
1489
+ return vibe_word_motion_target(motion, count) if %w[w e b W E B ge gE].include?(motion)
1172
1490
  return vibe_find_motion_target(motion, count) if motion.match?(/\A[fFtT].\z/)
1173
1491
  return vibe_percent_motion_target if motion == "%"
1174
1492
 
@@ -1222,9 +1540,11 @@ module Kward
1222
1540
  end_index = start_index
1223
1541
  if motion == "w"
1224
1542
  end_index = vibe_word_operator_forward_index(end_index, count)
1543
+ elsif motion == "W"
1544
+ end_index = vibe_big_word_operator_forward_index(end_index, count)
1225
1545
  else
1226
1546
  count.times { end_index = vibe_word_motion_index(motion, end_index) }
1227
- end_index = [end_index + 1, @editor_state.buffer.length].min if motion == "e"
1547
+ end_index = [end_index + 1, @editor_state.buffer.length].min if %w[e E].include?(motion)
1228
1548
  end
1229
1549
  @editor_state.cursor = end_index
1230
1550
  VibeOperatorTarget.new(type: :characterwise, start_index: start_index, end_index: end_index)
@@ -1251,8 +1571,18 @@ module Kward
1251
1571
  vibe_move_to_next_word_start
1252
1572
  when "e"
1253
1573
  vibe_move_to_word_end
1254
- else
1574
+ when "b"
1255
1575
  vibe_move_to_previous_word_start
1576
+ when "W"
1577
+ vibe_move_to_next_big_word_start
1578
+ when "E"
1579
+ vibe_move_to_big_word_end
1580
+ when "B"
1581
+ vibe_move_to_previous_big_word_start
1582
+ when "ge"
1583
+ vibe_move_to_previous_word_end
1584
+ else
1585
+ vibe_move_to_previous_big_word_end
1256
1586
  end
1257
1587
  @editor_state.cursor
1258
1588
  ensure
@@ -1464,6 +1794,14 @@ module Kward
1464
1794
  cursor
1465
1795
  end
1466
1796
 
1797
+ def vibe_jump_to_file_percentage(percent)
1798
+ percent = [[percent, 1].max, 100].min
1799
+ line_count = @editor_state.lines.length
1800
+ target_line = ((line_count - 1) * percent / 100.0).floor
1801
+ @editor_state.move_to_line_first_non_blank(target_line)
1802
+ true
1803
+ end
1804
+
1467
1805
  def vibe_jump_to_matching_pair
1468
1806
  index = vibe_matching_pair_index(@editor_state.cursor)
1469
1807
  unless index
@@ -1632,6 +1970,12 @@ module Kward
1632
1970
  count.times { vibe_move_to_word_end }
1633
1971
  when "b"
1634
1972
  count.times { vibe_move_to_previous_word_start }
1973
+ when "W"
1974
+ count.times { vibe_move_to_next_big_word_start }
1975
+ when "E"
1976
+ count.times { vibe_move_to_big_word_end }
1977
+ when "B"
1978
+ count.times { vibe_move_to_previous_big_word_start }
1635
1979
  else
1636
1980
  return vibe_apply_motion(motion, count)
1637
1981
  end
@@ -1652,6 +1996,8 @@ module Kward
1652
1996
  @editor_state.move_line_start
1653
1997
  when "^"
1654
1998
  @editor_state.move_line_first_non_blank
1999
+ when "|"
2000
+ @editor_state.set_cursor_line_and_column(@editor_state.cursor_line_and_column.first, [count - 1, 0].max)
1655
2001
  when "+", "\n", "\r"
1656
2002
  vibe_move_to_relative_line_first_non_blank(count)
1657
2003
  when "-"
@@ -1705,6 +2051,18 @@ module Kward
1705
2051
  @editor_state.cursor = cursor
1706
2052
  end
1707
2053
 
2054
+ def vibe_big_word_operator_forward_index(index, count)
2055
+ cursor = index
2056
+ buffer = @editor_state.buffer
2057
+ count.times do |step|
2058
+ cursor += 1 while cursor < buffer.length && vibe_word_kind(buffer[cursor]) != :space
2059
+ if step < count - 1
2060
+ cursor += 1 while cursor < buffer.length && vibe_word_kind(buffer[cursor]) == :space
2061
+ end
2062
+ end
2063
+ cursor
2064
+ end
2065
+
1708
2066
  def vibe_move_to_word_end
1709
2067
  cursor = @editor_state.cursor
1710
2068
  buffer = @editor_state.buffer
@@ -1721,6 +2079,22 @@ module Kward
1721
2079
  @editor_state.cursor = cursor
1722
2080
  end
1723
2081
 
2082
+ def vibe_move_to_previous_word_end
2083
+ cursor = @editor_state.cursor
2084
+ buffer = @editor_state.buffer
2085
+ return if cursor.zero? || buffer.empty?
2086
+
2087
+ cursor = [cursor - 1, buffer.length - 1].min
2088
+ cursor -= 1 while cursor.positive? && vibe_word_kind(buffer[cursor]) == :space
2089
+ if cursor.positive? && vibe_word_kind(buffer[cursor]) != :space
2090
+ current_kind = vibe_word_kind(buffer[cursor])
2091
+ cursor -= 1 while cursor.positive? && vibe_word_kind(buffer[cursor - 1]) == current_kind
2092
+ cursor -= 1 if cursor.positive?
2093
+ cursor -= 1 while cursor.positive? && vibe_word_kind(buffer[cursor]) == :space
2094
+ end
2095
+ @editor_state.cursor = cursor
2096
+ end
2097
+
1724
2098
  def vibe_move_to_previous_word_start
1725
2099
  cursor = @editor_state.cursor
1726
2100
  buffer = @editor_state.buffer
@@ -1733,6 +2107,55 @@ module Kward
1733
2107
  @editor_state.cursor = cursor
1734
2108
  end
1735
2109
 
2110
+ def vibe_move_to_next_big_word_start
2111
+ cursor = @editor_state.cursor
2112
+ buffer = @editor_state.buffer
2113
+ return if cursor >= buffer.length
2114
+
2115
+ cursor += 1 while cursor < buffer.length && vibe_word_kind(buffer[cursor]) != :space
2116
+ cursor += 1 while cursor < buffer.length && vibe_word_kind(buffer[cursor]) == :space
2117
+ @editor_state.cursor = cursor
2118
+ end
2119
+
2120
+ def vibe_move_to_big_word_end
2121
+ cursor = @editor_state.cursor
2122
+ buffer = @editor_state.buffer
2123
+ return if buffer.empty? || cursor >= buffer.length
2124
+
2125
+ cursor += 1 if vibe_word_kind(buffer[cursor]) != :space && cursor < buffer.length - 1 && vibe_word_kind(buffer[cursor + 1]) == :space
2126
+ cursor += 1 while cursor < buffer.length && vibe_word_kind(buffer[cursor]) == :space
2127
+ return @editor_state.cursor = cursor if cursor >= buffer.length
2128
+
2129
+ cursor += 1 while cursor < buffer.length - 1 && vibe_word_kind(buffer[cursor + 1]) != :space
2130
+ @editor_state.cursor = cursor
2131
+ end
2132
+
2133
+ def vibe_move_to_previous_big_word_start
2134
+ cursor = @editor_state.cursor
2135
+ buffer = @editor_state.buffer
2136
+ return if cursor.zero? || buffer.empty?
2137
+
2138
+ cursor -= 1
2139
+ cursor -= 1 while cursor.positive? && vibe_word_kind(buffer[cursor]) == :space
2140
+ cursor -= 1 while cursor.positive? && vibe_word_kind(buffer[cursor - 1]) != :space
2141
+ @editor_state.cursor = cursor
2142
+ end
2143
+
2144
+ def vibe_move_to_previous_big_word_end
2145
+ cursor = @editor_state.cursor
2146
+ buffer = @editor_state.buffer
2147
+ return if cursor.zero? || buffer.empty?
2148
+
2149
+ cursor = [cursor - 1, buffer.length - 1].min
2150
+ cursor -= 1 while cursor.positive? && vibe_word_kind(buffer[cursor]) == :space
2151
+ if cursor.positive? && vibe_word_kind(buffer[cursor]) != :space
2152
+ cursor -= 1 while cursor.positive? && vibe_word_kind(buffer[cursor - 1]) != :space
2153
+ cursor -= 1 if cursor.positive?
2154
+ cursor -= 1 while cursor.positive? && vibe_word_kind(buffer[cursor]) == :space
2155
+ end
2156
+ @editor_state.cursor = cursor
2157
+ end
2158
+
1736
2159
  def vibe_word_kind(char)
1737
2160
  case char.to_s
1738
2161
  when /\s/
@@ -1744,8 +2167,9 @@ module Kward
1744
2167
  end
1745
2168
  end
1746
2169
 
1747
- def vibe_copy_range(start_index, end_index, status)
2170
+ def vibe_copy_range(start_index, end_index, status, linewise: false)
1748
2171
  @editor_state.copy_range(start_index, end_index)
2172
+ @editor_state.vibe_kill_linewise = linewise
1749
2173
  @output_io.print(TerminalSequences.osc52(@editor_state.kill_buffer))
1750
2174
  @output_io.flush if @output_io.respond_to?(:flush)
1751
2175
  @editor_state.status = status
@@ -1765,6 +2189,8 @@ module Kward
1765
2189
  end
1766
2190
 
1767
2191
  def handle_vibe_repeat_change
2192
+ return execute_vibe_normal_command(@editor_state.vibe_pending.to_s + ".") unless @editor_state.vibe_pending.to_s.empty?
2193
+
1768
2194
  change = @editor_state.vibe_last_change
1769
2195
  return @editor_state.status = "No change to repeat" unless change
1770
2196
 
@@ -1774,6 +2200,7 @@ module Kward
1774
2200
 
1775
2201
  def vibe_begin_change_recording(command)
1776
2202
  @editor_state.vibe_last_change = vibe_change_keys(command)
2203
+ @editor_state.vibe_previous_change_cursor = @editor_state.cursor
1777
2204
  end
1778
2205
 
1779
2206
  def vibe_record_insert_change_key(key)
@@ -1785,6 +2212,7 @@ module Kward
1785
2212
 
1786
2213
  def vibe_remember_change(command)
1787
2214
  @editor_state.vibe_last_change = vibe_change_keys(command) if command
2215
+ @editor_state.vibe_previous_change_cursor = @editor_state.cursor
1788
2216
  end
1789
2217
 
1790
2218
  def vibe_build_change_command(operator, motion, count, motion_count)