kward 0.82.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -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/agent-tools.md +1 -0
  9. data/doc/api.md +4 -0
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +82 -20
  12. data/doc/editor.md +47 -22
  13. data/doc/files.md +12 -7
  14. data/doc/getting-started.md +3 -0
  15. data/doc/pan.md +19 -15
  16. data/doc/permissions.md +1 -0
  17. data/doc/platform-support.md +48 -0
  18. data/doc/releasing.md +12 -4
  19. data/doc/rpc.md +2 -2
  20. data/doc/sandboxing.md +5 -1
  21. data/doc/security.md +4 -3
  22. data/doc/shell.md +101 -60
  23. data/doc/tabs.md +3 -0
  24. data/doc/troubleshooting.md +12 -2
  25. data/doc/usage.md +7 -6
  26. data/kward.gemspec +5 -4
  27. data/lib/kward/agent.rb +7 -3
  28. data/lib/kward/ansi.rb +110 -10
  29. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  30. data/lib/kward/cli/auth_commands.rb +34 -13
  31. data/lib/kward/cli/commands.rb +83 -62
  32. data/lib/kward/cli/doctor.rb +39 -17
  33. data/lib/kward/cli/hook_commands.rb +22 -12
  34. data/lib/kward/cli/interactive_turn.rb +144 -21
  35. data/lib/kward/cli/project_skills_commands.rb +8 -4
  36. data/lib/kward/cli/prompt_interface.rb +27 -0
  37. data/lib/kward/cli/rendering.rb +15 -9
  38. data/lib/kward/cli/runtime_helpers.rb +260 -55
  39. data/lib/kward/cli/slash_commands.rb +21 -12
  40. data/lib/kward/cli/tabs.rb +91 -13
  41. data/lib/kward/cli/tool_summaries.rb +14 -0
  42. data/lib/kward/cli.rb +55 -7
  43. data/lib/kward/cli_transcript_formatter.rb +11 -4
  44. data/lib/kward/compaction/token_estimator.rb +12 -6
  45. data/lib/kward/config_files.rb +94 -63
  46. data/lib/kward/detached_run.rb +44 -0
  47. data/lib/kward/editor_prompt.rb +46 -0
  48. data/lib/kward/editor_prompt_session.rb +28 -0
  49. data/lib/kward/interactive_pty_runner.rb +102 -28
  50. data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
  51. data/lib/kward/kwshrc.rb +233 -0
  52. data/lib/kward/markdown_code_block.rb +136 -0
  53. data/lib/kward/model/client.rb +35 -39
  54. data/lib/kward/model/model_info.rb +3 -2
  55. data/lib/kward/model/payloads.rb +0 -2
  56. data/lib/kward/model/provider_catalog.rb +5 -0
  57. data/lib/kward/model/stream_parser.rb +20 -4
  58. data/lib/kward/pan/index.html.erb +3 -3
  59. data/lib/kward/pan/server.rb +23 -3
  60. data/lib/kward/persistent_shell_session.rb +843 -0
  61. data/lib/kward/project_files.rb +19 -6
  62. data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
  63. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  64. data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
  65. data/lib/kward/prompt_interface/editor/controller.rb +99 -33
  66. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  67. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  68. data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
  69. data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
  70. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  71. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/state.rb +21 -10
  73. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  74. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  75. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  76. data/lib/kward/prompt_interface/file_overlay.rb +92 -22
  77. data/lib/kward/prompt_interface/key_handler.rb +75 -0
  78. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  79. data/lib/kward/prompt_interface/project_browser.rb +443 -17
  80. data/lib/kward/prompt_interface/runtime_state.rb +55 -2
  81. data/lib/kward/prompt_interface/screen.rb +2 -2
  82. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  83. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  84. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  85. data/lib/kward/prompt_interface.rb +169 -18
  86. data/lib/kward/prompts/commands.rb +2 -1
  87. data/lib/kward/prompts.rb +1 -1
  88. data/lib/kward/pty_output_sink.rb +47 -0
  89. data/lib/kward/rpc/auth_manager.rb +1 -1
  90. data/lib/kward/rpc/server.rb +2 -1
  91. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  92. data/lib/kward/scratchpad_languages.rb +74 -0
  93. data/lib/kward/scratchpad_runner.rb +155 -29
  94. data/lib/kward/shell_prompt.rb +52 -0
  95. data/lib/kward/shell_prompt_session.rb +58 -0
  96. data/lib/kward/terminal_keys.rb +13 -0
  97. data/lib/kward/terminal_text.rb +121 -0
  98. data/lib/kward/text_matcher.rb +18 -0
  99. data/lib/kward/tools/open_editor.rb +41 -0
  100. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  101. data/lib/kward/tools/registry.rb +75 -8
  102. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  103. data/lib/kward/tools/run_shell_command.rb +24 -6
  104. data/lib/kward/tools/tool_call.rb +2 -1
  105. data/lib/kward/version.rb +1 -1
  106. data/templates/default/fulldoc/html/css/kward.css +0 -125
  107. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  108. data/templates/default/fulldoc/html/setup.rb +1 -1
  109. data/templates/default/kward_navigation.rb +1 -0
  110. data/templates/default/layout/html/footer.erb +10 -0
  111. data/templates/default/layout/html/headers.erb +23 -0
  112. data/templates/default/layout/html/layout.erb +6 -18
  113. data/templates/default/layout/html/setup.rb +41 -2
  114. metadata +44 -8
  115. 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,15 +222,23 @@ 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)
237
+ result = nil
195
238
  case key
196
239
  when "\e", TerminalKeys::CTRL_C, :escape
197
240
  @editor_state.vibe_command = ""
241
+ @editor_state.vibe_command_range = nil
198
242
  vibe_return_to_normal
199
243
  when "\b", "\x7F"
200
244
  @editor_state.vibe_command = @editor_state.vibe_command[0...-1].to_s
@@ -202,20 +246,25 @@ module Kward
202
246
  when "\t"
203
247
  vibe_complete_command_path
204
248
  when "\n", "\r"
205
- execute_vibe_command(@editor_state.vibe_command)
249
+ result = execute_vibe_command(@editor_state.vibe_command)
206
250
  else
207
251
  if printable_key?(key)
208
252
  @editor_state.vibe_command = @editor_state.vibe_command.to_s + key
209
253
  @editor_state.status = ":#{@editor_state.vibe_command}"
210
254
  end
211
255
  end
212
- true
256
+ result || true
213
257
  end
214
258
 
215
259
  def execute_vibe_command(command)
216
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
217
265
  @editor_state.vibe_mode = "normal"
218
266
  @editor_state.vibe_command = ""
267
+ action = nil
219
268
  case command
220
269
  when "w"
221
270
  save_editor
@@ -223,8 +272,19 @@ module Kward
223
272
  save_editor(Regexp.last_match(1))
224
273
  when /\Ae(!?)\s+(.+)\z/
225
274
  vibe_edit_file(Regexp.last_match(2), force: Regexp.last_match(1) == "!")
275
+ when "run all"
276
+ run_vibe_all_markdown_code_blocks
226
277
  when "run"
227
- 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
284
+ when /\Aprompt\s+(.+)\z/
285
+ action = editor_prompt_action(Regexp.last_match(1))
286
+ when "prompt"
287
+ @editor_state.status = "Prompt instruction required"
228
288
  when "q"
229
289
  vibe_quit_editor
230
290
  when "q!"
@@ -237,16 +297,144 @@ module Kward
237
297
  save_editor if @editor_state&.dirty?
238
298
  close_editor if @editor_state
239
299
  when /\A(?:(%|\d+,\d+))?s\/([^\/]*)\/([^\/]*)\/(g?)\z/
240
- 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")
241
301
  when /\A\d+\z/
242
302
  @editor_state.set_cursor_line_and_column(command.to_i - 1, 0)
243
303
  @editor_state.status = "Line #{command}"
244
304
  else
245
305
  @editor_state.status = "Unknown command: #{command}"
246
306
  end
307
+ action || true
308
+ end
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
247
420
  true
248
421
  end
249
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
+
250
438
  def vibe_edit_file(path, force: false)
251
439
  if @editor_state.dirty? && !force
252
440
  @editor_state.status = "No write since last change (:e! overrides)"
@@ -258,6 +446,8 @@ module Kward
258
446
 
259
447
  def vibe_complete_command_path
260
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 != ""
261
451
  match = command.match(/\A(e!?)\s+(.*)\z/)
262
452
  return false unless match
263
453
 
@@ -270,7 +460,7 @@ module Kward
270
460
 
271
461
  replacement = candidates.length == 1 ? candidates.first : vibe_common_prefix(candidates)
272
462
  if replacement.length > prefix.length
273
- @editor_state.vibe_command = "#{match[1]} #{replacement}"
463
+ @editor_state.vibe_command = "#{range_prefix}#{match[1]} #{replacement}"
274
464
  @editor_state.status = ":#{@editor_state.vibe_command}"
275
465
  elsif candidates.length > 1
276
466
  @editor_state.status = vibe_path_completion_status(candidates)
@@ -330,7 +520,9 @@ module Kward
330
520
 
331
521
  start_line = 0
332
522
  end_line = @editor_state.lines.length - 1
333
- if range&.include?(",")
523
+ if range.is_a?(Array)
524
+ start_line, end_line = range
525
+ elsif range&.include?(",")
334
526
  start_line, end_line = range.split(",", 2).map { |value| value.to_i - 1 }
335
527
  end
336
528
  start_line = [[start_line, 0].max, @editor_state.lines.length - 1].min
@@ -400,8 +592,6 @@ module Kward
400
592
  @editor_state.page_up(editor_page_rows)
401
593
  when :ctrl_f
402
594
  @editor_state.page_down(editor_page_rows)
403
- when :ctrl_d
404
- @editor_state.page_down(vibe_half_page_rows)
405
595
  when :ctrl_u
406
596
  @editor_state.page_up(vibe_half_page_rows)
407
597
  when :ctrl_e
@@ -416,7 +606,7 @@ module Kward
416
606
  end
417
607
 
418
608
  def vibe_normal_control_key?(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)
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)
420
610
  end
421
611
 
422
612
  def vibe_visual_mode?
@@ -424,9 +614,9 @@ module Kward
424
614
  end
425
615
 
426
616
  def vibe_return_to_normal
427
- vibe_apply_visual_block_insert if @editor_state.vibe_visual_block_insert
617
+ @editor_state.collapse_to_primary_selection if @editor_state.multi_cursor?
428
618
  @editor_state.vibe_mode = "normal"
429
- @editor_state.status = "NORMAL · i insert · :w save · :q quit"
619
+ @editor_state.status = "NORMAL · i insert · :prompt ask agent · :w save · :q quit"
430
620
  true
431
621
  end
432
622
 
@@ -545,8 +735,6 @@ module Kward
545
735
  @editor_state.page_down(editor_page_rows)
546
736
  when TerminalKeys::CTRL_B
547
737
  @editor_state.page_up(editor_page_rows)
548
- when TerminalKeys::CTRL_D
549
- @editor_state.page_down(vibe_half_page_rows)
550
738
  when TerminalKeys::CTRL_U
551
739
  @editor_state.page_up(vibe_half_page_rows)
552
740
  when TerminalKeys::CTRL_E
@@ -647,6 +835,7 @@ module Kward
647
835
  when "u"
648
836
  @editor_state.undo
649
837
  when ":"
838
+ @editor_state.vibe_command_range = nil
650
839
  @editor_state.vibe_mode = "command"
651
840
  @editor_state.vibe_command = ""
652
841
  @editor_state.status = ":"
@@ -668,6 +857,13 @@ module Kward
668
857
  end
669
858
 
670
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
+
671
867
  key_name = key_name_for(key)
672
868
  return handle_vibe_visual_named_key(key_name) if key_name
673
869
  if key == "\e" || key == TerminalKeys::CTRL_C
@@ -702,9 +898,9 @@ module Kward
702
898
  when "p"
703
899
  vibe_paste_visual_selection
704
900
  when "I"
705
- vibe_begin_visual_block_insert(:before)
901
+ vibe_begin_visual_multi_cursor_insert(:before)
706
902
  when "A"
707
- vibe_begin_visual_block_insert(:after)
903
+ vibe_begin_visual_multi_cursor_insert(:after)
708
904
  when ">"
709
905
  vibe_indent_visual_selection(:right)
710
906
  when "<"
@@ -729,6 +925,8 @@ module Kward
729
925
  editor_search_repeat(vibe_opposite_search_direction)
730
926
  when "o"
731
927
  vibe_switch_visual_selection_end
928
+ when ":"
929
+ vibe_begin_visual_command
732
930
  when "G"
733
931
  vibe_visual_goto_line(command.match?(/\A\d+G\z/) ? count : nil)
734
932
  when "gg"
@@ -779,6 +977,14 @@ module Kward
779
977
  true
780
978
  end
781
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
+
782
988
  def vibe_jump_ruby_method(direction)
783
989
  unless vibe_ruby_file?
784
990
  @editor_state.status = "Ruby navigation requires Ruby file"
@@ -952,6 +1158,71 @@ module Kward
952
1158
  vibe_apply_cursor_motion(motion, count)
953
1159
  end
954
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
+
955
1226
  def vibe_visual_range
956
1227
  @editor_state.selection_range
957
1228
  end
@@ -1007,39 +1278,19 @@ module Kward
1007
1278
  vibe_cancel_visual_mode
1008
1279
  end
1009
1280
 
1010
- def vibe_begin_visual_block_insert(position)
1011
- return vibe_move_visual_selection(position == :before ? "I" : "A") unless @editor_state.vibe_mode == "visual_block"
1012
-
1013
- anchor_line, anchor_column = @editor_state.cursor_line_and_column_for(@editor_state.selection_anchor)
1014
- cursor_line, cursor_column = @editor_state.cursor_line_and_column
1015
- start_line, end_line = [anchor_line, cursor_line].minmax
1016
- start_column, end_column = [anchor_column, cursor_column].minmax
1017
- column = position == :before ? start_column : end_column + 1
1018
- @editor_state.vibe_visual_block_insert = { start_line: start_line, end_line: end_line, column: column }
1019
- @editor_state.clear_selection
1020
- @editor_state.set_cursor_line_and_column(start_line, column)
1021
- @editor_state.vibe_visual_block_insert[:start_index] = @editor_state.cursor
1022
- @editor_state.vibe_mode = "insert"
1023
- @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")
1024
1291
  true
1025
1292
  end
1026
1293
 
1027
- def vibe_apply_visual_block_insert
1028
- block = @editor_state.vibe_visual_block_insert
1029
- @editor_state.vibe_visual_block_insert = nil
1030
- return unless block
1031
-
1032
- inserted_text = @editor_state.buffer[block[:start_index]...@editor_state.cursor].to_s
1033
- return if inserted_text.empty?
1034
-
1035
- block[:end_line].downto(block[:start_line] + 1) do |line_index|
1036
- line_start = @editor_state.line_start_offset(line_index)
1037
- line_length = @editor_state.lines[line_index].to_s.length
1038
- @editor_state.cursor = line_start + [block[:column], line_length].min
1039
- @editor_state.insert(inserted_text)
1040
- end
1041
- end
1042
-
1043
1294
  def vibe_transform_visual_selection(transform)
1044
1295
  range = vibe_visual_range
1045
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
@@ -264,6 +366,7 @@ module Kward
264
366
 
265
367
  def editor_status_text
266
368
  text = @editor_state.search_active ? "#{@editor_state.search_direction == :backward ? "Search backward" : "Search"}: #{@editor_state.search_query}" : @editor_state.status
369
+ text = "#{text} · #{spinner_frame}" if @editor_agent_suspended && @busy
267
370
  visible_truncate(text, [screen_width - 4, 1].max)
268
371
  end
269
372
  end