kward 0.73.1 → 0.75.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +70 -4
  7. data/doc/extensibility.md +6 -19
  8. data/doc/getting-started.md +1 -1
  9. data/doc/lifecycle-hooks.md +424 -0
  10. data/doc/mcp.md +72 -0
  11. data/doc/memory.md +1 -1
  12. data/doc/personas.md +2 -2
  13. data/doc/plugins.md +1 -1
  14. data/doc/releasing.md +7 -15
  15. data/doc/rpc.md +108 -15
  16. data/doc/session-management.md +9 -9
  17. data/doc/skills.md +199 -0
  18. data/doc/usage.md +14 -7
  19. data/lib/kward/agent.rb +72 -22
  20. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  21. data/lib/kward/auth/oauth_helpers.rb +105 -0
  22. data/lib/kward/auth/openai_oauth.rb +4 -86
  23. data/lib/kward/cli/commands.rb +12 -2
  24. data/lib/kward/cli/compaction.rb +7 -0
  25. data/lib/kward/cli/doctor.rb +18 -10
  26. data/lib/kward/cli/git.rb +30 -3
  27. data/lib/kward/cli/hook_commands.rb +258 -0
  28. data/lib/kward/cli/interactive_turn.rb +9 -3
  29. data/lib/kward/cli/memory_commands.rb +1 -5
  30. data/lib/kward/cli/plugins.rb +41 -1
  31. data/lib/kward/cli/prompt_interface.rb +32 -3
  32. data/lib/kward/cli/rendering.rb +1 -1
  33. data/lib/kward/cli/runtime_helpers.rb +44 -5
  34. data/lib/kward/cli/sessions.rb +13 -0
  35. data/lib/kward/cli/settings.rb +226 -152
  36. data/lib/kward/cli/slash_commands.rb +81 -42
  37. data/lib/kward/cli/tabs.rb +32 -15
  38. data/lib/kward/cli.rb +64 -4
  39. data/lib/kward/cli_transcript_formatter.rb +0 -7
  40. data/lib/kward/compaction/token_estimator.rb +112 -0
  41. data/lib/kward/compactor.rb +42 -116
  42. data/lib/kward/config_files.rb +152 -27
  43. data/lib/kward/deep_copy.rb +42 -0
  44. data/lib/kward/diff_view_mode.rb +36 -0
  45. data/lib/kward/events.rb +1 -0
  46. data/lib/kward/export_path.rb +2 -6
  47. data/lib/kward/frontmatter.rb +43 -0
  48. data/lib/kward/hooks/audit_log.rb +121 -0
  49. data/lib/kward/hooks/catalog.rb +110 -0
  50. data/lib/kward/hooks/command_handler.rb +66 -0
  51. data/lib/kward/hooks/config_loader.rb +104 -0
  52. data/lib/kward/hooks/decision.rb +102 -0
  53. data/lib/kward/hooks/event.rb +60 -0
  54. data/lib/kward/hooks/http_handler.rb +72 -0
  55. data/lib/kward/hooks/manager.rb +158 -0
  56. data/lib/kward/hooks/matcher.rb +75 -0
  57. data/lib/kward/hooks.rb +16 -0
  58. data/lib/kward/markdown_transcript.rb +4 -3
  59. data/lib/kward/mcp/client.rb +56 -0
  60. data/lib/kward/mcp/server_config.rb +59 -0
  61. data/lib/kward/mcp/stdio_transport.rb +112 -0
  62. data/lib/kward/memory/turn_context.rb +19 -0
  63. data/lib/kward/message_access.rb +2 -2
  64. data/lib/kward/message_text.rb +19 -0
  65. data/lib/kward/model/client.rb +13 -45
  66. data/lib/kward/model/context_usage.rb +10 -4
  67. data/lib/kward/model/copilot_models.rb +44 -0
  68. data/lib/kward/model/model_info.rb +24 -0
  69. data/lib/kward/model/payloads.rb +15 -17
  70. data/lib/kward/model/stream_parser.rb +178 -13
  71. data/lib/kward/pan/server.rb +51 -2
  72. data/lib/kward/path_guard.rb +13 -0
  73. data/lib/kward/plugin_registry.rb +86 -25
  74. data/lib/kward/prompt_interface/editor/controller.rb +124 -4
  75. data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
  76. data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
  77. data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
  78. data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
  79. data/lib/kward/prompt_interface/editor/search.rb +70 -13
  80. data/lib/kward/prompt_interface/editor/state.rb +72 -13
  81. data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -1
  82. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  83. data/lib/kward/prompt_interface.rb +4 -1
  84. data/lib/kward/prompts/commands.rb +3 -3
  85. data/lib/kward/prompts.rb +7 -7
  86. data/lib/kward/rpc/mcp_status.rb +50 -0
  87. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  88. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  89. data/lib/kward/rpc/server.rb +94 -13
  90. data/lib/kward/rpc/session_manager.rb +258 -33
  91. data/lib/kward/rpc/session_metrics.rb +1 -1
  92. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  93. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  94. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  95. data/lib/kward/rpc/turn_context.rb +109 -0
  96. data/lib/kward/session_store.rb +6 -20
  97. data/lib/kward/session_tree_nodes.rb +98 -18
  98. data/lib/kward/session_tree_renderer.rb +6 -61
  99. data/lib/kward/skills/registry.rb +109 -17
  100. data/lib/kward/tools/mcp_tool.rb +122 -0
  101. data/lib/kward/tools/read_skill.rb +9 -4
  102. data/lib/kward/tools/registry.rb +297 -9
  103. data/lib/kward/tools/search/code.rb +4 -22
  104. data/lib/kward/tools/search/web.rb +28 -2
  105. data/lib/kward/update_check.rb +117 -0
  106. data/lib/kward/version.rb +1 -1
  107. data/lib/kward/workers/manager.rb +43 -3
  108. data/lib/kward/workspace.rb +3 -1
  109. data/templates/default/fulldoc/html/css/kward.css +22 -0
  110. data/templates/default/fulldoc/html/js/kward.js +2 -0
  111. data/templates/default/kward_navigation.rb +4 -2
  112. data/templates/default/layout/html/setup.rb +2 -0
  113. metadata +30 -1
@@ -1,4 +1,6 @@
1
1
  require_relative "config_files"
2
+ require_relative "deep_copy"
3
+ require_relative "hooks"
2
4
 
3
5
  # Namespace for the Kward CLI agent runtime.
4
6
  module Kward
@@ -39,6 +41,9 @@ module Kward
39
41
  end
40
42
  end
41
43
 
44
+ # Registered lifecycle hook handler.
45
+ HookHandler = Struct.new(:event, :id, :description, :path, :order, :match, :failure_policy, :handler, keyword_init: true)
46
+
42
47
  # Read-only transcript view exposed to plugin code.
43
48
  class Transcript
44
49
  # Creates an object for trusted plugin loading and dispatch.
@@ -50,7 +55,7 @@ module Kward
50
55
  #
51
56
  # @return [Array<Hash>] immutable transcript message data
52
57
  def messages
53
- PluginRegistry.deep_freeze(PluginRegistry.deep_dup(@conversation.messages))
58
+ DeepCopy.freeze(DeepCopy.dup(@conversation.messages))
54
59
  end
55
60
  end
56
61
 
@@ -102,6 +107,34 @@ module Kward
102
107
  @conversation.refresh_system_message! if @conversation.respond_to?(:refresh_system_message!)
103
108
  nil
104
109
  end
110
+
111
+ def allow(message = nil, metadata: nil)
112
+ Hooks::Decision.allow(message, metadata: metadata)
113
+ end
114
+
115
+ def deny(message = nil, metadata: nil)
116
+ Hooks::Decision.deny(message, metadata: metadata)
117
+ end
118
+
119
+ def ask(message = nil, metadata: nil)
120
+ Hooks::Decision.ask(message, metadata: metadata)
121
+ end
122
+
123
+ def modify(payload, message: nil, metadata: nil)
124
+ Hooks::Decision.modify(payload, message: message, metadata: metadata)
125
+ end
126
+
127
+ def warn(message = nil, metadata: nil)
128
+ Hooks::Decision.warn(message, metadata: metadata)
129
+ end
130
+
131
+ def retry(message = nil, payload: nil, metadata: nil)
132
+ Hooks::Decision.retry(message, payload: payload, metadata: metadata)
133
+ end
134
+
135
+ def defer(message = nil, payload: nil, metadata: nil)
136
+ Hooks::Decision.defer(message, payload: payload, metadata: metadata)
137
+ end
105
138
  end
106
139
 
107
140
  # Public DSL object yielded by `Kward.plugin` blocks.
@@ -165,6 +198,25 @@ module Kward
165
198
  @registry.register_transcript_event(path: @path, &block)
166
199
  end
167
200
 
201
+ # Registers a lifecycle hook handler.
202
+ #
203
+ # Hooks are deterministic runtime callbacks around Kward lifecycle events.
204
+ # They can return a {Hooks::Decision}, a decision hash, a decision string,
205
+ # or nil to allow the operation.
206
+ #
207
+ # @param event [String, #to_s] lifecycle event name
208
+ # @param id [String, nil] stable hook identifier for logs and diagnostics
209
+ # @param description [String] short human-readable purpose
210
+ # @param order [Integer] lower values run first
211
+ # @param match [Hash, nil] optional event selector
212
+ # @yieldparam event [Hooks::Event] immutable lifecycle event
213
+ # @yieldparam ctx [Context] plugin execution context and decision helpers
214
+ # @return [void]
215
+ # @api public
216
+ def hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, &block)
217
+ @registry.register_hook(event, id: id, description: description, order: order, match: match, failure_policy: failure_policy, path: @path, &block)
218
+ end
219
+
168
220
  # Registers prompt context text injected into future system prompts.
169
221
  #
170
222
  # Keep this text short and never include secrets. The returned string can
@@ -205,29 +257,6 @@ module Kward
205
257
  paths.each { |path| registry.load_file(path) }
206
258
  registry
207
259
  end
208
-
209
- def deep_dup(value)
210
- case value
211
- when Hash
212
- value.each_with_object({}) { |(key, item), result| result[key] = deep_dup(item) }
213
- when Array
214
- value.map { |item| deep_dup(item) }
215
- else
216
- value.dup
217
- end
218
- rescue TypeError
219
- value
220
- end
221
-
222
- def deep_freeze(value)
223
- case value
224
- when Hash
225
- value.each_value { |item| deep_freeze(item) }
226
- when Array
227
- value.each { |item| deep_freeze(item) }
228
- end
229
- value.freeze
230
- end
231
260
  end
232
261
 
233
262
  # Creates an object for trusted plugin loading and dispatch.
@@ -239,6 +268,7 @@ module Kward
239
268
  @footer_path = nil
240
269
  @transcript_event_handlers = []
241
270
  @prompt_context_renderers = []
271
+ @hook_handlers = []
242
272
  @paths = []
243
273
  end
244
274
 
@@ -276,6 +306,20 @@ module Kward
276
306
  @prompt_context_renderers.map { |entry| entry[:renderer] }
277
307
  end
278
308
 
309
+ def hook_handlers
310
+ @hook_handlers.dup
311
+ end
312
+
313
+ def hook_manager
314
+ manager = Hooks::Manager.new
315
+ @hook_handlers.each do |hook|
316
+ manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
317
+ hook.handler.call(event, context)
318
+ end
319
+ end
320
+ manager
321
+ end
322
+
279
323
  def prompt_context(context)
280
324
  parts = []
281
325
  @prompt_context_renderers.each do |entry|
@@ -387,6 +431,23 @@ module Kward
387
431
  @prompt_context_renderers << { path: path, renderer: renderer }
388
432
  end
389
433
 
434
+ def register_hook(event, id: nil, description: "", order: 100, match: nil, failure_policy: nil, path: nil, &handler)
435
+ event = event.to_s
436
+ raise "Plugin hook event is required" if event.empty?
437
+ raise "Plugin hook #{event} requires a handler" unless handler
438
+
439
+ @hook_handlers << HookHandler.new(
440
+ event: event,
441
+ id: id&.to_s || "#{File.basename(path.to_s.empty? ? "plugin" : path)}:#{event}:#{@hook_handlers.length + 1}",
442
+ description: description.to_s,
443
+ path: path,
444
+ order: order.to_i,
445
+ match: match,
446
+ failure_policy: failure_policy,
447
+ handler: handler
448
+ )
449
+ end
450
+
390
451
  private
391
452
 
392
453
  def transcript_event_for(event)
@@ -422,7 +483,7 @@ module Kward
422
483
  def transcript_event(type, payload)
423
484
  TranscriptEvent.new(
424
485
  type: type,
425
- payload: PluginRegistry.deep_freeze(PluginRegistry.deep_dup(payload))
486
+ payload: DeepCopy.freeze(DeepCopy.dup(payload))
426
487
  ).freeze
427
488
  end
428
489
  end
@@ -112,7 +112,9 @@ module Kward
112
112
  end
113
113
 
114
114
  def open_diff_viewer(path, content)
115
- @editor_state = EditorState.new(path: path.to_s, content: content.to_s, new_file: true, editor_mode: current_editor_mode, readonly: true, diff_view: true)
115
+ diff_view_mode = current_diff_view_mode
116
+ viewer_content = diff_view_mode == DiffViewMode::SIDE_BY_SIDE ? side_by_side_diff_content(content.to_s) : content.to_s
117
+ @editor_state = EditorState.new(path: path.to_s, content: viewer_content, new_file: true, editor_mode: current_editor_mode, readonly: true, diff_view: diff_view_mode)
116
118
  @prompt_label = "Diff>"
117
119
  self.composer_input = ""
118
120
  self.composer_cursor = 0
@@ -131,6 +133,63 @@ module Kward
131
133
  @editor_mode
132
134
  end
133
135
 
136
+ def current_diff_view_mode
137
+ configured = @diff_view_source.respond_to?(:call) ? @diff_view_source.call : @diff_view
138
+ DiffViewMode.resolve(configured, terminal_width: screen_width)
139
+ rescue StandardError
140
+ DiffViewMode.resolve(@diff_view, terminal_width: screen_width)
141
+ end
142
+
143
+ def side_by_side_diff_content(content)
144
+ width = side_by_side_diff_column_width
145
+ rows = []
146
+ pending_deletions = []
147
+
148
+ content.to_s.each_line(chomp: true) do |line|
149
+ if side_by_side_diff_metadata_line?(line)
150
+ rows.concat(flush_side_by_side_deletions(pending_deletions, width))
151
+ rows << line
152
+ elsif line.start_with?("-")
153
+ pending_deletions << line[1..].to_s
154
+ elsif line.start_with?("+")
155
+ deleted = pending_deletions.shift
156
+ rows << side_by_side_diff_row(deleted && "- #{deleted}", "+ #{line[1..]}", width)
157
+ else
158
+ rows.concat(flush_side_by_side_deletions(pending_deletions, width))
159
+ text = line.start_with?(" ") ? line[1..].to_s : line
160
+ rows << side_by_side_diff_row(" #{text}", " #{text}", width)
161
+ end
162
+ end
163
+
164
+ rows.concat(flush_side_by_side_deletions(pending_deletions, width))
165
+ rows.join("\n")
166
+ end
167
+
168
+ def side_by_side_diff_metadata_line?(line)
169
+ line.start_with?("diff --git", "index ", "---", "+++", "@@")
170
+ end
171
+
172
+ def flush_side_by_side_deletions(lines, width)
173
+ lines.shift(lines.length).map { |line| side_by_side_diff_row("- #{line}", nil, width) }
174
+ end
175
+
176
+ def side_by_side_diff_row(left, right, width)
177
+ left_text = side_by_side_diff_cell(left, width)
178
+ right_text = side_by_side_diff_cell(right, width)
179
+ "#{left_text.ljust(width)} │ #{right_text}"
180
+ end
181
+
182
+ def side_by_side_diff_cell(text, width)
183
+ value = text.nil? ? "" : text.to_s
184
+ visible_truncate(value, width)
185
+ end
186
+
187
+ def side_by_side_diff_column_width
188
+ content_width = [screen_width - 4, 1].max
189
+ gutter_width = [[999.to_s.length, 4].max + 3, 1].max
190
+ [[(content_width - gutter_width - 3) / 2, 10].max, 80].min
191
+ end
192
+
134
193
  def current_editor_soft_wrap?
135
194
  return @editor_soft_wrap_source.call != false if @editor_soft_wrap_source.respond_to?(:call)
136
195
 
@@ -279,6 +338,34 @@ module Kward
279
338
  end
280
339
  end
281
340
 
341
+ def handle_editor_search_csi_u_key(sequence, enter: :confirm, restore_cursor_on_cancel: true)
342
+ result = case sequence[:code]
343
+ when 13
344
+ if enter == :repeat
345
+ shift_modifier?(sequence[:modifier]) ? editor_search_repeat(:backward) : editor_search_repeat(:forward)
346
+ else
347
+ editor_search_confirm
348
+ end
349
+ when 27
350
+ editor_search_cancel(restore_cursor: restore_cursor_on_cancel)
351
+ when 8, 127
352
+ editor_search_delete_character
353
+ else
354
+ normalized_code = ctrl_code(sequence[:code])
355
+ if ctrl_modifier?(sequence[:modifier]) && normalized_code == 103
356
+ shift_modifier?(sequence[:modifier]) ? editor_search_repeat(:backward) : editor_search_repeat(:forward)
357
+ elsif (text = csi_u_printable_text(sequence))
358
+ editor_search_append(text)
359
+ elsif csi_u_text_field?(sequence)
360
+ true
361
+ else
362
+ false
363
+ end
364
+ end
365
+ queue_pending_keys(sequence[:remaining]) if result != false && sequence[:remaining] && !sequence[:remaining].empty?
366
+ result
367
+ end
368
+
282
369
  def handle_editor_mouse_key(key)
283
370
  event = parse_editor_mouse_key(key)
284
371
  return false unless event
@@ -902,32 +989,40 @@ module Kward
902
989
 
903
990
  def editor_search_begin(direction = :forward)
904
991
  @editor_state.begin_search(direction)
992
+ ensure_editor_cursor_visible
905
993
  true
906
994
  end
907
995
 
908
996
  def editor_search_append(text)
909
997
  @editor_state.append_search(text)
998
+ ensure_editor_cursor_visible
910
999
  true
911
1000
  end
912
1001
 
913
1002
  def editor_search_delete_character
914
1003
  @editor_state.delete_search_character
1004
+ ensure_editor_cursor_visible
915
1005
  true
916
1006
  end
917
1007
 
918
- def editor_search_confirm
1008
+ def editor_search_confirm(clear_highlights: true)
919
1009
  @editor_state.confirm_search
1010
+ @editor_state.clear_search_highlights if clear_highlights
1011
+ ensure_editor_cursor_visible
920
1012
  true
921
1013
  end
922
1014
 
923
- def editor_search_cancel
924
- @editor_state.cancel_search
1015
+ def editor_search_cancel(restore_cursor: true)
1016
+ @editor_state.cancel_search(restore_cursor: restore_cursor)
1017
+ ensure_editor_cursor_visible
925
1018
  true
926
1019
  end
927
1020
 
928
1021
  def editor_search_repeat(direction = nil)
929
1022
  direction ||= @editor_state.search_direction
930
1023
  @editor_state.repeat_search(direction)
1024
+ @editor_state.clear_search_highlights unless @editor_state.search_active
1025
+ ensure_editor_cursor_visible
931
1026
  true
932
1027
  end
933
1028
 
@@ -939,6 +1034,31 @@ module Kward
939
1034
  end
940
1035
 
941
1036
  @editor_state.repeat_search(direction, query)
1037
+ @editor_state.clear_search_highlights
1038
+ ensure_editor_cursor_visible
1039
+ true
1040
+ end
1041
+
1042
+ def ensure_editor_cursor_visible
1043
+ return false unless @editor_state
1044
+
1045
+ content_width = [screen_width - 4, 1].max
1046
+ visible_count = editor_visible_line_count
1047
+ line_index, column = @editor_state.cursor_line_and_column
1048
+ gutter_width = editor_line_number_gutter_width
1049
+ text_width = editor_text_width(content_width, gutter_width)
1050
+ sync_editor_wrap_state(text_width)
1051
+
1052
+ if current_editor_soft_wrap?
1053
+ cursor_visual_row = editor_visual_row_for(line_index, column, text_width)
1054
+ @editor_state.viewport_row = [[@editor_state.viewport_row, cursor_visual_row - visible_count + 1].max, cursor_visual_row].min
1055
+ @editor_state.viewport_row = [@editor_state.viewport_row, 0].max
1056
+ else
1057
+ @editor_state.viewport_row = [[@editor_state.viewport_row, line_index - visible_count + 1].max, line_index].min
1058
+ @editor_state.viewport_row = [@editor_state.viewport_row, 0].max
1059
+ @editor_state.viewport_column = [[@editor_state.viewport_column.to_i, column - text_width + 1].max, column].min
1060
+ @editor_state.viewport_column = [@editor_state.viewport_column, 0].max
1061
+ end
942
1062
  true
943
1063
  end
944
1064
 
@@ -112,6 +112,10 @@ module Kward
112
112
  def handle_emacs_csi_u_key(key)
113
113
  sequence = parse_csi_u_key(key)
114
114
  return false unless sequence
115
+ if editor_search_active?
116
+ search_result = handle_editor_search_csi_u_key(sequence)
117
+ return search_result unless search_result == false
118
+ end
115
119
 
116
120
  code = sequence[:code]
117
121
  modifier = sequence[:modifier]
@@ -37,16 +37,18 @@ module Kward
37
37
 
38
38
  case key
39
39
  when "\n", "\r"
40
- return editor_search_confirm if editor_search_active?
40
+ return editor_search_repeat(:forward) if editor_search_active?
41
41
  modern_record_undo { modern_insert_text("\n") }
42
42
  when "\t"
43
43
  modern_record_undo { editor_insert_tab unless editor_search_active? }
44
44
  when "\b", "\x7F"
45
45
  editor_search_active? ? editor_search_delete_character : modern_record_undo { modern_delete_before_cursor }
46
+ when "\a"
47
+ return editor_search_repeat(:forward) if editor_search_active?
46
48
  when TerminalKeys::CTRL_C
47
49
  return editor_search_cancel if editor_search_active?
48
50
  when "\e"
49
- return editor_search_cancel if editor_search_active?
51
+ return editor_search_cancel(restore_cursor: false) if editor_search_active?
50
52
  return @editor_state.collapse_to_primary_selection if @editor_state.multi_cursor?
51
53
  return @editor_state.clear_selection if @editor_state.selection_active?
52
54
  when "/"
@@ -79,6 +81,10 @@ module Kward
79
81
  def handle_modern_csi_u_key(key)
80
82
  sequence = parse_csi_u_key(key)
81
83
  return false unless sequence
84
+ if editor_search_active?
85
+ search_result = handle_editor_search_csi_u_key(sequence, enter: :repeat, restore_cursor_on_cancel: false)
86
+ return search_result unless search_result == false
87
+ end
82
88
 
83
89
  code = sequence[:code]
84
90
  modifier = sequence[:modifier]
@@ -197,7 +203,7 @@ module Kward
197
203
  when TerminalKeys::CTRL_C
198
204
  editor_search_active? ? editor_search_cancel : copy_editor_selection
199
205
  when TerminalKeys::CTRL_F
200
- @editor_state.move_right unless editor_search_active?
206
+ editor_search_begin(:forward) unless editor_search_active?
201
207
  when TerminalKeys::CTRL_V
202
208
  modern_record_undo { @editor_state.yank_kill_buffer } unless editor_search_active?
203
209
  when TerminalKeys::CTRL_X
@@ -229,7 +235,7 @@ module Kward
229
235
 
230
236
  @editor_state.add_next_occurrence_selection
231
237
  when 102
232
- @editor_state.move_right unless editor_search_active?
238
+ editor_search_begin(:forward) unless editor_search_active?
233
239
  when 118
234
240
  modern_record_undo { @editor_state.yank_kill_buffer } unless editor_search_active?
235
241
  when 120