kward 0.83.0 → 0.84.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/api.md +4 -0
  9. data/doc/composer.md +1 -1
  10. data/doc/configuration.md +68 -21
  11. data/doc/editor.md +28 -13
  12. data/doc/files.md +8 -4
  13. data/doc/getting-started.md +3 -0
  14. data/doc/pan.md +19 -15
  15. data/doc/platform-support.md +48 -0
  16. data/doc/security.md +3 -2
  17. data/doc/shell.md +62 -45
  18. data/doc/troubleshooting.md +12 -2
  19. data/doc/usage.md +5 -5
  20. data/kward.gemspec +5 -4
  21. data/lib/kward/agent.rb +6 -3
  22. data/lib/kward/ansi.rb +110 -10
  23. data/lib/kward/cli/auth_commands.rb +34 -13
  24. data/lib/kward/cli/commands.rb +83 -62
  25. data/lib/kward/cli/doctor.rb +39 -17
  26. data/lib/kward/cli/hook_commands.rb +22 -12
  27. data/lib/kward/cli/interactive_turn.rb +48 -7
  28. data/lib/kward/cli/project_skills_commands.rb +8 -4
  29. data/lib/kward/cli/prompt_interface.rb +27 -0
  30. data/lib/kward/cli/rendering.rb +15 -9
  31. data/lib/kward/cli/runtime_helpers.rb +118 -55
  32. data/lib/kward/cli/slash_commands.rb +12 -14
  33. data/lib/kward/cli/tabs.rb +83 -12
  34. data/lib/kward/cli/tool_summaries.rb +14 -0
  35. data/lib/kward/cli.rb +45 -7
  36. data/lib/kward/cli_transcript_formatter.rb +11 -4
  37. data/lib/kward/config_files.rb +82 -68
  38. data/lib/kward/detached_run.rb +44 -0
  39. data/lib/kward/interactive_pty_runner.rb +102 -28
  40. data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
  41. data/lib/kward/kwshrc.rb +233 -0
  42. data/lib/kward/markdown_code_block.rb +136 -0
  43. data/lib/kward/model/client.rb +34 -22
  44. data/lib/kward/model/provider_catalog.rb +5 -0
  45. data/lib/kward/model/stream_parser.rb +20 -4
  46. data/lib/kward/pan/index.html.erb +3 -3
  47. data/lib/kward/pan/server.rb +23 -3
  48. data/lib/kward/persistent_shell_session.rb +119 -26
  49. data/lib/kward/project_files.rb +2 -2
  50. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  51. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  52. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  53. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  54. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  55. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  56. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  57. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  58. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  59. data/lib/kward/prompt_interface/editor/state.rb +10 -10
  60. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  61. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  62. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  63. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  64. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  65. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  66. data/lib/kward/prompt_interface/runtime_state.rb +50 -1
  67. data/lib/kward/prompt_interface/screen.rb +2 -2
  68. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  69. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  70. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  71. data/lib/kward/prompt_interface.rb +93 -18
  72. data/lib/kward/prompts/commands.rb +1 -1
  73. data/lib/kward/prompts.rb +1 -1
  74. data/lib/kward/pty_output_sink.rb +47 -0
  75. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  76. data/lib/kward/scratchpad_languages.rb +74 -0
  77. data/lib/kward/scratchpad_runner.rb +155 -29
  78. data/lib/kward/shell_prompt.rb +2 -0
  79. data/lib/kward/terminal_keys.rb +12 -0
  80. data/lib/kward/terminal_text.rb +121 -0
  81. data/lib/kward/text_matcher.rb +18 -0
  82. data/lib/kward/tools/open_editor.rb +41 -0
  83. data/lib/kward/tools/registry.rb +13 -4
  84. data/lib/kward/tools/tool_call.rb +2 -1
  85. data/lib/kward/version.rb +1 -1
  86. data/templates/default/fulldoc/html/css/kward.css +0 -125
  87. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  88. data/templates/default/fulldoc/html/setup.rb +1 -1
  89. data/templates/default/kward_navigation.rb +1 -0
  90. data/templates/default/layout/html/footer.erb +10 -0
  91. data/templates/default/layout/html/headers.erb +23 -0
  92. data/templates/default/layout/html/layout.erb +6 -18
  93. data/templates/default/layout/html/setup.rb +41 -2
  94. metadata +36 -8
  95. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -17,6 +17,9 @@ module Kward
17
17
  VIBE_RUBY_BLOCK_OPENERS = %w[if unless case while until for def module class do begin].freeze
18
18
  VIBE_RUBY_PATHS = %w[Gemfile Rakefile Guardfile Capfile Vagrantfile].freeze
19
19
  VIBE_RUBY_EXTENSIONS = %w[.rb .rake .gemspec].freeze
20
+ VIBE_VISUAL_LINE_MOVE_CONTROLS = {
21
+ TerminalKeys::CTRL_H => "h", TerminalKeys::CTRL_J => "j", TerminalKeys::CTRL_K => "k", TerminalKeys::CTRL_L => "l"
22
+ }.freeze
20
23
 
21
24
  VibeOperatorTarget = Struct.new(:type, :start_index, :end_index, :replacement_text, :replacement_cursor_offset, keyword_init: true) do
22
25
  def characterwise?
@@ -38,6 +41,9 @@ module Kward
38
41
  csi_result = handle_vibe_csi_u_key(key)
39
42
  return csi_result unless csi_result == false
40
43
 
44
+ multi_cursor_result = handle_vibe_multi_cursor_key(key)
45
+ return multi_cursor_result unless multi_cursor_result == false
46
+
41
47
  return handle_vibe_command_key(key) if @editor_state.vibe_mode == "command"
42
48
 
43
49
  tab_result = handle_tab_key_binding(key)
@@ -84,6 +90,7 @@ module Kward
84
90
 
85
91
  @editor_state.vibe_command = ""
86
92
  @editor_state.vibe_pending = ""
93
+ @editor_state.vibe_command_range = nil
87
94
  @editor_state.clear_selection
88
95
  vibe_return_to_normal
89
96
  end
@@ -123,6 +130,35 @@ module Kward
123
130
  nil
124
131
  end
125
132
 
133
+ def handle_vibe_multi_cursor_key(key)
134
+ return false unless key == TerminalKeys::CTRL_D
135
+ return false if editor_search_active?
136
+
137
+ visual_selection = @editor_state.vibe_mode == "visual"
138
+ return false unless visual_selection || %w[normal insert replace].include?(@editor_state.vibe_mode)
139
+
140
+ original_selection = vibe_prepare_visual_selection_for_multi_cursor if visual_selection
141
+ added = @editor_state.add_next_occurrence_selection
142
+ if added
143
+ vibe_enter_insert_mode(TerminalKeys::CTRL_D) unless @editor_state.vibe_mode == "insert"
144
+ elsif original_selection
145
+ @editor_state.vibe_mode = "visual"
146
+ @editor_state.set_selections([original_selection])
147
+ end
148
+ true
149
+ end
150
+
151
+ def vibe_prepare_visual_selection_for_multi_cursor
152
+ range = @editor_state.selection_range
153
+ return unless range
154
+
155
+ original_selection = { anchor: @editor_state.selection_anchor, cursor: @editor_state.cursor }
156
+ vibe_remember_visual_selection
157
+ @editor_state.vibe_mode = "normal"
158
+ @editor_state.set_selections([{ anchor: range[0], cursor: range[1] }])
159
+ original_selection
160
+ end
161
+
126
162
  def handle_vibe_search_key(key)
127
163
  case key
128
164
  when "\n", "\r"
@@ -159,7 +195,7 @@ module Kward
159
195
  named_result = handle_vibe_insert_named_key(key_name) if key_name
160
196
  return named_result unless named_result == false || named_result.nil?
161
197
 
162
- vibe_record_undo { editor_insert_printable(key) } if printable_key?(key)
198
+ vibe_record_undo { vibe_insert_printable(key) } if printable_key?(key)
163
199
  end
164
200
  end
165
201
 
@@ -186,9 +222,15 @@ module Kward
186
222
  end
187
223
  end
188
224
 
225
+ def vibe_insert_printable(text)
226
+ return editor_insert_printable(text) unless @editor_state.multi_cursor?
227
+
228
+ @editor_state.replace_selections(text)
229
+ end
230
+
189
231
  def vibe_replace_character(key)
190
232
  @editor_state.delete_at_cursor
191
- editor_insert_printable(key)
233
+ vibe_insert_printable(key)
192
234
  end
193
235
 
194
236
  def handle_vibe_command_key(key)
@@ -196,6 +238,7 @@ module Kward
196
238
  case key
197
239
  when "\e", TerminalKeys::CTRL_C, :escape
198
240
  @editor_state.vibe_command = ""
241
+ @editor_state.vibe_command_range = nil
199
242
  vibe_return_to_normal
200
243
  when "\b", "\x7F"
201
244
  @editor_state.vibe_command = @editor_state.vibe_command[0...-1].to_s
@@ -215,6 +258,10 @@ module Kward
215
258
 
216
259
  def execute_vibe_command(command)
217
260
  command = command.to_s.strip
261
+ command_range = @editor_state.vibe_command_range
262
+ command_range ||= vibe_visual_line_bounds if vibe_visual_mode?
263
+ command_range, command = vibe_extract_command_range(command, command_range)
264
+ @editor_state.vibe_command_range = nil
218
265
  @editor_state.vibe_mode = "normal"
219
266
  @editor_state.vibe_command = ""
220
267
  action = nil
@@ -225,8 +272,15 @@ module Kward
225
272
  save_editor(Regexp.last_match(1))
226
273
  when /\Ae(!?)\s+(.+)\z/
227
274
  vibe_edit_file(Regexp.last_match(2), force: Regexp.last_match(1) == "!")
275
+ when "run all"
276
+ run_vibe_all_markdown_code_blocks
228
277
  when "run"
229
- vibe_record_undo { run_editor_buffer }
278
+ block = vibe_markdown_code_block_for_run(command_range)
279
+ if block
280
+ run_vibe_markdown_code_block(block)
281
+ else
282
+ vibe_record_undo { run_editor_buffer(source: vibe_command_source(command_range)) }
283
+ end
230
284
  when /\Aprompt\s+(.+)\z/
231
285
  action = editor_prompt_action(Regexp.last_match(1))
232
286
  when "prompt"
@@ -243,7 +297,7 @@ module Kward
243
297
  save_editor if @editor_state&.dirty?
244
298
  close_editor if @editor_state
245
299
  when /\A(?:(%|\d+,\d+))?s\/([^\/]*)\/([^\/]*)\/(g?)\z/
246
- vibe_substitute_command(Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3), global: Regexp.last_match(4) == "g")
300
+ vibe_substitute_command(command_range || Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3), global: Regexp.last_match(4) == "g")
247
301
  when /\A\d+\z/
248
302
  @editor_state.set_cursor_line_and_column(command.to_i - 1, 0)
249
303
  @editor_state.status = "Line #{command}"
@@ -253,6 +307,134 @@ module Kward
253
307
  action || true
254
308
  end
255
309
 
310
+ def vibe_extract_command_range(command, visual_range)
311
+ if command.start_with?("'<,'>")
312
+ [visual_range, command[5..].to_s.strip]
313
+ elsif (match = command.match(/\A(%|\d+,\d+)(?=[a-z])/))
314
+ [match[1], command[match[1].length..].to_s]
315
+ else
316
+ [visual_range, command]
317
+ end
318
+ end
319
+
320
+ def vibe_command_source(range)
321
+ return @editor_state.buffer.dup unless range
322
+
323
+ first_line, last_line = vibe_command_line_bounds(range)
324
+ start_index = @editor_state.line_range(first_line)[0]
325
+ end_index = @editor_state.line_range(last_line)[1]
326
+ @editor_state.buffer[start_index...end_index].to_s
327
+ end
328
+
329
+ def vibe_command_line_bounds(range)
330
+ return range if range.is_a?(Array)
331
+ return [0, @editor_state.lines.length - 1] if range.to_s == "%"
332
+
333
+ first_line, last_line = range.to_s.split(",", 2).map { |value| value.to_i - 1 }
334
+ [first_line, last_line]
335
+ end
336
+
337
+ def vibe_markdown_code_block_for_run(command_range)
338
+ if command_range.is_a?(Array)
339
+ MarkdownCodeBlock.for_selection(@editor_state.buffer, *command_range)
340
+ else
341
+ line, = @editor_state.cursor_line_and_column
342
+ MarkdownCodeBlock.for_cursor(@editor_state.buffer, line)
343
+ end
344
+ end
345
+
346
+ def run_vibe_markdown_code_block(block)
347
+ document = block.source.dup
348
+ language = normalize_vibe_code_block_language(block.language)
349
+ unless language
350
+ @editor_state.status = "Selected code block has no runnable language"
351
+ return false
352
+ end
353
+
354
+ start_vibe_markdown_code_block_run(block, language) do |result, error|
355
+ if @editor_state.buffer != document
356
+ @editor_state.status = "Output not inserted: buffer changed"
357
+ next
358
+ end
359
+
360
+ output = error ? "Run failed: #{error.message}" : result.output
361
+ @editor_state.replace_buffer(block.with_output(output))
362
+ @editor_state.status = error ? "Output updated · Run failed" : vibe_code_block_result_status(result)
363
+ end
364
+ end
365
+
366
+ def run_vibe_all_markdown_code_blocks
367
+ document = @editor_state.buffer.dup
368
+ runs = MarkdownCodeBlock.parse(document).filter_map do |block|
369
+ language = normalize_vibe_code_block_language(block.language)
370
+ [block, language] if language
371
+ end
372
+ if runs.empty?
373
+ @editor_state.status = "No runnable code blocks"
374
+ return false
375
+ end
376
+
377
+ results = []
378
+ run_next = nil
379
+ run_next = lambda do
380
+ block, language = runs.shift
381
+ start_vibe_markdown_code_block_run(block, language) do |result, error|
382
+ output = error ? "Run failed: #{error.message}" : result.output
383
+ results << [block, output, result, error]
384
+ if runs.empty?
385
+ finish_vibe_all_markdown_code_blocks(document, results)
386
+ else
387
+ run_next.call
388
+ end
389
+ end
390
+ end
391
+ run_next.call
392
+ end
393
+
394
+ def start_vibe_markdown_code_block_run(block, language, &completion)
395
+ run_editor_buffer(
396
+ source: block.code,
397
+ language: language,
398
+ source_path: ScratchpadLanguages.display_path(language),
399
+ output_visible: false,
400
+ completion: completion
401
+ )
402
+ end
403
+
404
+ def finish_vibe_all_markdown_code_blocks(document, results)
405
+ if @editor_state.buffer != document
406
+ @editor_state.status = "Output not inserted: buffer changed"
407
+ return false
408
+ end
409
+
410
+ content = document.dup
411
+ results.reverse_each do |block, output, _result, _error|
412
+ start_index, end_index, replacement = block.output_edit(output)
413
+ content[start_index...end_index] = replacement
414
+ end
415
+ @editor_state.replace_buffer(content)
416
+ failures = results.count { |_block, _output, result, error| error || result.exit_status != 0 }
417
+ status = "Ran #{results.length} code blocks"
418
+ status += " · #{failures} failed" if failures.positive?
419
+ @editor_state.status = status
420
+ true
421
+ end
422
+
423
+ def vibe_code_block_result_status(result)
424
+ return "Output updated · Canceled" if result.cancelled
425
+
426
+ "Output updated · Exit #{result.exit_status} · #{format_runner_duration(result.duration)}"
427
+ end
428
+
429
+ def normalize_vibe_code_block_language(language)
430
+ return nil if language.to_s.strip.empty?
431
+
432
+ normalized = ScratchpadLanguages.normalize(language)
433
+ ScratchpadLanguages.runnable?(normalized) ? normalized : nil
434
+ rescue ArgumentError
435
+ nil
436
+ end
437
+
256
438
  def vibe_edit_file(path, force: false)
257
439
  if @editor_state.dirty? && !force
258
440
  @editor_state.status = "No write since last change (:e! overrides)"
@@ -264,6 +446,8 @@ module Kward
264
446
 
265
447
  def vibe_complete_command_path
266
448
  command = @editor_state.vibe_command.to_s
449
+ range_prefix = command.start_with?("'<,'>") ? command[0, 5] : ""
450
+ command = command[5..].to_s if range_prefix != ""
267
451
  match = command.match(/\A(e!?)\s+(.*)\z/)
268
452
  return false unless match
269
453
 
@@ -276,7 +460,7 @@ module Kward
276
460
 
277
461
  replacement = candidates.length == 1 ? candidates.first : vibe_common_prefix(candidates)
278
462
  if replacement.length > prefix.length
279
- @editor_state.vibe_command = "#{match[1]} #{replacement}"
463
+ @editor_state.vibe_command = "#{range_prefix}#{match[1]} #{replacement}"
280
464
  @editor_state.status = ":#{@editor_state.vibe_command}"
281
465
  elsif candidates.length > 1
282
466
  @editor_state.status = vibe_path_completion_status(candidates)
@@ -336,7 +520,9 @@ module Kward
336
520
 
337
521
  start_line = 0
338
522
  end_line = @editor_state.lines.length - 1
339
- if range&.include?(",")
523
+ if range.is_a?(Array)
524
+ start_line, end_line = range
525
+ elsif range&.include?(",")
340
526
  start_line, end_line = range.split(",", 2).map { |value| value.to_i - 1 }
341
527
  end
342
528
  start_line = [[start_line, 0].max, @editor_state.lines.length - 1].min
@@ -406,8 +592,6 @@ module Kward
406
592
  @editor_state.page_up(editor_page_rows)
407
593
  when :ctrl_f
408
594
  @editor_state.page_down(editor_page_rows)
409
- when :ctrl_d
410
- @editor_state.page_down(vibe_half_page_rows)
411
595
  when :ctrl_u
412
596
  @editor_state.page_up(vibe_half_page_rows)
413
597
  when :ctrl_e
@@ -422,7 +606,7 @@ module Kward
422
606
  end
423
607
 
424
608
  def vibe_normal_control_key?(key)
425
- ["\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)
609
+ ["\n", "\r", "\b", "\x7F", TerminalKeys::CTRL_B, TerminalKeys::CTRL_E, TerminalKeys::CTRL_F, "\x0F", TerminalKeys::CTRL_R, TerminalKeys::CTRL_U, TerminalKeys::CTRL_Y].include?(key)
426
610
  end
427
611
 
428
612
  def vibe_visual_mode?
@@ -430,7 +614,7 @@ module Kward
430
614
  end
431
615
 
432
616
  def vibe_return_to_normal
433
- vibe_apply_visual_block_insert if @editor_state.vibe_visual_block_insert
617
+ @editor_state.collapse_to_primary_selection if @editor_state.multi_cursor?
434
618
  @editor_state.vibe_mode = "normal"
435
619
  @editor_state.status = "NORMAL · i insert · :prompt ask agent · :w save · :q quit"
436
620
  true
@@ -551,8 +735,6 @@ module Kward
551
735
  @editor_state.page_down(editor_page_rows)
552
736
  when TerminalKeys::CTRL_B
553
737
  @editor_state.page_up(editor_page_rows)
554
- when TerminalKeys::CTRL_D
555
- @editor_state.page_down(vibe_half_page_rows)
556
738
  when TerminalKeys::CTRL_U
557
739
  @editor_state.page_up(vibe_half_page_rows)
558
740
  when TerminalKeys::CTRL_E
@@ -653,6 +835,7 @@ module Kward
653
835
  when "u"
654
836
  @editor_state.undo
655
837
  when ":"
838
+ @editor_state.vibe_command_range = nil
656
839
  @editor_state.vibe_mode = "command"
657
840
  @editor_state.vibe_command = ""
658
841
  @editor_state.status = ":"
@@ -674,6 +857,13 @@ module Kward
674
857
  end
675
858
 
676
859
  def handle_vibe_visual_key(key)
860
+ if (direction = VIBE_VISUAL_LINE_MOVE_CONTROLS[key])
861
+ count, = vibe_count_and_body(@editor_state.vibe_pending.to_s + direction)
862
+ count = 1 if count.zero?
863
+ @editor_state.vibe_pending = ""
864
+ return vibe_move_selected_lines(direction, count)
865
+ end
866
+
677
867
  key_name = key_name_for(key)
678
868
  return handle_vibe_visual_named_key(key_name) if key_name
679
869
  if key == "\e" || key == TerminalKeys::CTRL_C
@@ -708,9 +898,9 @@ module Kward
708
898
  when "p"
709
899
  vibe_paste_visual_selection
710
900
  when "I"
711
- vibe_begin_visual_block_insert(:before)
901
+ vibe_begin_visual_multi_cursor_insert(:before)
712
902
  when "A"
713
- vibe_begin_visual_block_insert(:after)
903
+ vibe_begin_visual_multi_cursor_insert(:after)
714
904
  when ">"
715
905
  vibe_indent_visual_selection(:right)
716
906
  when "<"
@@ -735,6 +925,8 @@ module Kward
735
925
  editor_search_repeat(vibe_opposite_search_direction)
736
926
  when "o"
737
927
  vibe_switch_visual_selection_end
928
+ when ":"
929
+ vibe_begin_visual_command
738
930
  when "G"
739
931
  vibe_visual_goto_line(command.match?(/\A\d+G\z/) ? count : nil)
740
932
  when "gg"
@@ -785,6 +977,14 @@ module Kward
785
977
  true
786
978
  end
787
979
 
980
+ def vibe_begin_visual_command
981
+ @editor_state.vibe_command_range = vibe_visual_line_bounds
982
+ @editor_state.vibe_mode = "command"
983
+ @editor_state.vibe_command = "'<,'>"
984
+ @editor_state.status = ":'<,'>"
985
+ true
986
+ end
987
+
788
988
  def vibe_jump_ruby_method(direction)
789
989
  unless vibe_ruby_file?
790
990
  @editor_state.status = "Ruby navigation requires Ruby file"
@@ -958,6 +1158,71 @@ module Kward
958
1158
  vibe_apply_cursor_motion(motion, count)
959
1159
  end
960
1160
 
1161
+ def vibe_move_selected_lines(direction, count)
1162
+ @editor_state.status = case @editor_state.vibe_mode
1163
+ when "visual_line" then "VISUAL LINE"
1164
+ when "visual_block" then "VISUAL BLOCK"
1165
+ else "VISUAL"
1166
+ end
1167
+ start_line, end_line = vibe_visual_line_bounds
1168
+ original_lines = @editor_state.lines.dup
1169
+ lines = original_lines.dup
1170
+ endpoint_positions = [@editor_state.selection_anchor, @editor_state.cursor].map do |offset|
1171
+ @editor_state.cursor_line_and_column_for(offset)
1172
+ end
1173
+ line_shift = 0
1174
+
1175
+ case direction
1176
+ when "h", "l"
1177
+ moved_lines = lines[start_line..end_line].map(&:dup)
1178
+ count.times do
1179
+ moved_lines = moved_lines.map do |line|
1180
+ if direction == "l"
1181
+ " #{line}"
1182
+ else
1183
+ line.sub(/\A(?: |\t| )/, "")
1184
+ end
1185
+ end
1186
+ end
1187
+ lines[start_line..end_line] = moved_lines
1188
+ when "k"
1189
+ line_shift = -[count, start_line].min
1190
+ return true if line_shift.zero?
1191
+
1192
+ selected_lines = lines.slice!(start_line..end_line)
1193
+ lines.insert(start_line + line_shift, *selected_lines)
1194
+ when "j"
1195
+ line_shift = [count, lines.length - end_line - 1].min
1196
+ return true if line_shift.zero?
1197
+
1198
+ selected_lines = lines.slice!(start_line..end_line)
1199
+ lines.insert(start_line + line_shift, *selected_lines)
1200
+ end
1201
+
1202
+ return false if lines == @editor_state.lines
1203
+
1204
+ @editor_state.replace_buffer(lines.join("\n"))
1205
+ selection_offsets = endpoint_positions.map do |line, column|
1206
+ new_line = line + line_shift
1207
+ new_column = if direction == "l"
1208
+ column + (lines[line].length - original_lines[line].length)
1209
+ elsif direction == "h"
1210
+ [column - (original_lines[line].length - lines[line].length), 0].max
1211
+ else
1212
+ column
1213
+ end
1214
+ @editor_state.offset_for_line_and_column(new_line, new_column)
1215
+ end
1216
+ @editor_state.set_selections([{ anchor: selection_offsets[0], cursor: selection_offsets[1] }])
1217
+ true
1218
+ end
1219
+
1220
+ def vibe_visual_line_bounds
1221
+ anchor_line, = @editor_state.cursor_line_and_column_for(@editor_state.selection_anchor)
1222
+ cursor_line, = @editor_state.cursor_line_and_column
1223
+ [anchor_line, cursor_line].minmax
1224
+ end
1225
+
961
1226
  def vibe_visual_range
962
1227
  @editor_state.selection_range
963
1228
  end
@@ -1013,39 +1278,19 @@ module Kward
1013
1278
  vibe_cancel_visual_mode
1014
1279
  end
1015
1280
 
1016
- def vibe_begin_visual_block_insert(position)
1017
- return vibe_move_visual_selection(position == :before ? "I" : "A") unless @editor_state.vibe_mode == "visual_block"
1018
-
1019
- anchor_line, anchor_column = @editor_state.cursor_line_and_column_for(@editor_state.selection_anchor)
1020
- cursor_line, cursor_column = @editor_state.cursor_line_and_column
1021
- start_line, end_line = [anchor_line, cursor_line].minmax
1022
- start_column, end_column = [anchor_column, cursor_column].minmax
1023
- column = position == :before ? start_column : end_column + 1
1024
- @editor_state.vibe_visual_block_insert = { start_line: start_line, end_line: end_line, column: column }
1025
- @editor_state.clear_selection
1026
- @editor_state.set_cursor_line_and_column(start_line, column)
1027
- @editor_state.vibe_visual_block_insert[:start_index] = @editor_state.cursor
1028
- @editor_state.vibe_mode = "insert"
1029
- @editor_state.status = "INSERT · Esc normal"
1281
+ def vibe_begin_visual_multi_cursor_insert(position)
1282
+ vibe_remember_visual_selection
1283
+ start_line, end_line = vibe_visual_line_bounds
1284
+ selections = (start_line..end_line).map do |line_index|
1285
+ column = position == :before ? 0 : @editor_state.lines[line_index].to_s.length
1286
+ offset = @editor_state.offset_for_line_and_column(line_index, column)
1287
+ { anchor: offset, cursor: offset }
1288
+ end
1289
+ @editor_state.set_selections(selections)
1290
+ vibe_enter_insert_mode(position == :before ? "I" : "A")
1030
1291
  true
1031
1292
  end
1032
1293
 
1033
- def vibe_apply_visual_block_insert
1034
- block = @editor_state.vibe_visual_block_insert
1035
- @editor_state.vibe_visual_block_insert = nil
1036
- return unless block
1037
-
1038
- inserted_text = @editor_state.buffer[block[:start_index]...@editor_state.cursor].to_s
1039
- return if inserted_text.empty?
1040
-
1041
- block[:end_line].downto(block[:start_line] + 1) do |line_index|
1042
- line_start = @editor_state.line_start_offset(line_index)
1043
- line_length = @editor_state.lines[line_index].to_s.length
1044
- @editor_state.cursor = line_start + [block[:column], line_length].min
1045
- @editor_state.insert(inserted_text)
1046
- end
1047
- end
1048
-
1049
1294
  def vibe_transform_visual_selection(transform)
1050
1295
  range = vibe_visual_range
1051
1296
  return false unless range
@@ -7,6 +7,8 @@ module Kward
7
7
  private
8
8
 
9
9
  def editor_layout(width, height = screen_height)
10
+ return editor_split_layout(width, height) if editor_runner_output_visible?
11
+
10
12
  content_width = [width - 4, 1].max
11
13
  visible_count = editor_visible_line_count(height: height, width: width)
12
14
  line_index, column = @editor_state.cursor_line_and_column
@@ -21,7 +23,107 @@ module Kward
21
23
  end
22
24
  end
23
25
 
24
- def editor_unwrapped_layout(width, content_width, visible_count, line_index, column, text_width)
26
+ def editor_split_layout(width, height)
27
+ content_width = [width - 4, 1].max
28
+ source_count, output_count = editor_split_visible_counts(height: height, width: width)
29
+ line_index, column = @editor_state.cursor_line_and_column
30
+ gutter_width = editor_line_number_gutter_width
31
+ text_width = editor_text_width(content_width, gutter_width)
32
+ sync_editor_wrap_state(text_width)
33
+ source_rows, cursor_row, cursor_col = if current_editor_soft_wrap?
34
+ editor_wrapped_layout(width, content_width, source_count, line_index, column, text_width, include_bottom: false)
35
+ else
36
+ editor_unwrapped_layout(width, content_width, source_count, line_index, column, text_width, include_bottom: false)
37
+ end
38
+ rows = source_rows + editor_runner_output_rows(width, content_width, output_count)
39
+ rows.concat(editor_bottom_rows(width))
40
+ [rows, cursor_row, cursor_col]
41
+ end
42
+
43
+ def editor_split_visible_counts(height:, width:)
44
+ # Keep one row available above the composer, matching the normal editor layout.
45
+ available = height - editor_bottom_rows(width).length - 5
46
+ available = 2 if available < 2
47
+ source_count = (available + 1) / 2
48
+ [source_count, available - source_count]
49
+ end
50
+
51
+ def editor_runner_output_rows(width, content_width, visible_count, include_bottom: false)
52
+ lines = editor_runner_output_lines(content_width)
53
+ maximum_scroll = [lines.length - visible_count, 0].max
54
+ @editor_runner_state.set_scroll_row(@editor_runner_state.scroll_row, maximum: maximum_scroll)
55
+ visible_lines = lines[@editor_runner_state.scroll_row, visible_count] || []
56
+ visible_lines << "" while visible_lines.length < visible_count
57
+ rows = [editor_runner_output_top_border(width)]
58
+ rows.concat(visible_lines.each_with_index.map do |line, index|
59
+ line_index = @editor_runner_state.scroll_row + index
60
+ selection = editor_runner_selection_range_for(line_index, ANSI.strip(line).length)
61
+ line = editor_overlay_selection(line, [selection], force: true) if selection
62
+ box_content_row(line, content_width)
63
+ end)
64
+ rows << footer_row(content_width, editor_runner_output_status)
65
+ rows.concat(editor_bottom_rows(width)) if include_bottom
66
+ rows
67
+ end
68
+
69
+ def editor_runner_output_visible_count(height: screen_height, width: screen_width)
70
+ editor_split_visible_counts(height: height, width: width).last
71
+ end
72
+
73
+ def editor_runner_output_scroll_max
74
+ width = [screen_width - 4, 1].max
75
+ lines = editor_runner_output_lines(width)
76
+ [lines.length - editor_runner_output_visible_count, 0].max
77
+ end
78
+
79
+ def editor_runner_output_lines(width)
80
+ text = ANSI.sanitize_transcript(@editor_runner_state.output_text)
81
+ text = ANSI.strip(text) unless @color_enabled
82
+ text = text.chomp
83
+ return ["(no output)"] if text.empty?
84
+
85
+ text.split("\n", -1).flat_map do |line|
86
+ wrapped = ANSI.wrap_visible(line, width)
87
+ wrapped.empty? ? [""] : wrapped
88
+ end
89
+ end
90
+
91
+ def editor_runner_output_top_border(width)
92
+ title = visible_truncate("Output #{editor_display_path}", [width - 4, 1].max)
93
+ plain_title = ANSI.strip(title)
94
+ "#{colored("╭", :primary_green)} #{title} #{colored("─" * [width - plain_title.length - 4, 0].max, :primary_green)}#{colored("╮", :primary_green)}"
95
+ end
96
+
97
+ def editor_runner_output_status
98
+ state = @editor_runner_state
99
+ if state.running?
100
+ return "Canceling #{state.language} · please wait" if state.cancel_requested?
101
+
102
+ return "Running #{state.language} · Ctrl+C cancel · Esc close"
103
+ end
104
+
105
+ if (error = state.error)
106
+ return "Run failed: #{error.message} · Esc close"
107
+ end
108
+
109
+ result = state.result
110
+ status = if result.cancelled
111
+ "Canceled"
112
+ else
113
+ "Exit #{result.exit_status}"
114
+ end
115
+ status += " · #{format_runner_duration(result.duration)}"
116
+ status += " · output truncated" if result.truncated
117
+ status += " · source changed" if editor_runner_output_stale?
118
+ "#{status} · Ctrl+R rerun · Esc close"
119
+ end
120
+
121
+ def editor_runner_output_stale?
122
+ digest = Digest::SHA256.hexdigest(@editor_state.buffer.to_s)
123
+ digest != @editor_runner_state.source_digest
124
+ end
125
+
126
+ def editor_unwrapped_layout(width, content_width, visible_count, line_index, column, text_width, include_bottom: true)
25
127
  @editor_state.viewport_row = [[@editor_state.viewport_row, line_index - visible_count + 1].max, line_index].min
26
128
  @editor_state.viewport_row = [@editor_state.viewport_row, 0].max
27
129
  @editor_state.viewport_column = [[@editor_state.viewport_column.to_i, column - text_width + 1].max, column].min
@@ -43,13 +145,13 @@ module Kward
43
145
  box_content_row(row, content_width)
44
146
  end)
45
147
  rows << footer_row(content_width, editor_status_text)
46
- rows.concat(editor_bottom_rows(width))
148
+ rows.concat(editor_bottom_rows(width)) if include_bottom
47
149
  cursor_row = 1 + line_index - @editor_state.viewport_row
48
150
  cursor_col = 2 + gutter_width + [[column - @editor_state.viewport_column, 0].max, text_width - 1].min
49
151
  [rows, cursor_row, cursor_col]
50
152
  end
51
153
 
52
- def editor_wrapped_layout(width, content_width, visible_count, line_index, column, text_width)
154
+ def editor_wrapped_layout(width, content_width, visible_count, line_index, column, text_width, include_bottom: true)
53
155
  visual_rows = editor_visual_rows(text_width)
54
156
  cursor_visual_row = editor_visual_row_for(line_index, column, text_width)
55
157
  @editor_state.viewport_row = [[@editor_state.viewport_row, cursor_visual_row - visible_count + 1].max, cursor_visual_row].min
@@ -67,7 +169,7 @@ module Kward
67
169
  end
68
170
  end)
69
171
  rows << footer_row(content_width, editor_status_text)
70
- rows.concat(editor_bottom_rows(width))
172
+ rows.concat(editor_bottom_rows(width)) if include_bottom
71
173
  line_start = editor_visual_row_start_column(line_index, column, text_width)
72
174
  cursor_row = 1 + cursor_visual_row - @editor_state.viewport_row
73
175
  cursor_col = 2 + editor_line_number_gutter_width + [[column - line_start, 0].max, text_width - 1].min
@@ -138,8 +240,8 @@ module Kward
138
240
  editor_overlay_selection(rendered, cursor_columns.map { |column| [column, column + 1] })
139
241
  end
140
242
 
141
- def editor_overlay_selection(rendered, selection_ranges)
142
- return rendered unless @color_enabled
243
+ def editor_overlay_selection(rendered, selection_ranges, force: false)
244
+ return rendered unless @color_enabled || force
143
245
 
144
246
  output = +""
145
247
  selected = false