kward 0.73.1 → 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.
@@ -0,0 +1,106 @@
1
+ require "json"
2
+ require "open3"
3
+ require "timeout"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Model Context Protocol client support.
8
+ module MCP
9
+ # JSON-RPC transport for local MCP servers that communicate over stdio.
10
+ class StdioTransport
11
+ DEFAULT_TIMEOUT_SECONDS = 10
12
+
13
+ def initialize(command:, args: [], env: {}, timeout_seconds: DEFAULT_TIMEOUT_SECONDS)
14
+ @command = command.to_s
15
+ @args = Array(args).map(&:to_s)
16
+ @env = env || {}
17
+ @timeout_seconds = timeout_seconds || DEFAULT_TIMEOUT_SECONDS
18
+ @mutex = Mutex.new
19
+ @started = false
20
+ end
21
+
22
+ def request(method, params = nil)
23
+ @mutex.synchronize do
24
+ start
25
+ id = next_id
26
+ write_message({ jsonrpc: "2.0", id: id, method: method, params: params }.compact)
27
+ read_response(id)
28
+ end
29
+ end
30
+
31
+ def notify(method, params = nil)
32
+ @mutex.synchronize do
33
+ start
34
+ write_message({ jsonrpc: "2.0", method: method, params: params }.compact)
35
+ end
36
+ end
37
+
38
+ def close
39
+ @stdin&.close unless @stdin&.closed?
40
+ @stdout&.close unless @stdout&.closed?
41
+ @stderr&.close unless @stderr&.closed?
42
+ @stderr_thread&.kill
43
+ terminate_process
44
+ rescue IOError
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ def terminate_process
51
+ return unless @wait_thread&.alive?
52
+
53
+ Process.kill("TERM", @wait_thread.pid)
54
+ @wait_thread.join(1)
55
+ Process.kill("KILL", @wait_thread.pid) if @wait_thread.alive?
56
+ rescue Errno::ESRCH, IOError
57
+ nil
58
+ end
59
+
60
+ def start
61
+ return if @started
62
+ raise ArgumentError, "MCP server command is required" if @command.empty?
63
+
64
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command, *@args)
65
+ @stderr_thread = Thread.new { @stderr.each_line { |_line| } }
66
+ @started = true
67
+ rescue SystemCallError => e
68
+ raise "Failed to start MCP server #{@command}: #{e.message}"
69
+ end
70
+
71
+ def next_id
72
+ @next_id ||= 0
73
+ @next_id += 1
74
+ end
75
+
76
+ def write_message(message)
77
+ @stdin.write(JSON.generate(message))
78
+ @stdin.write("\n")
79
+ @stdin.flush
80
+ rescue IOError, Errno::EPIPE => e
81
+ raise "MCP server #{@command} is not accepting requests: #{e.message}"
82
+ end
83
+
84
+ def read_response(expected_id)
85
+ Timeout.timeout(@timeout_seconds) do
86
+ loop do
87
+ line = @stdout.gets
88
+ raise "MCP server #{@command} closed stdout" unless line
89
+
90
+ message = JSON.parse(line)
91
+ next unless message["id"] == expected_id
92
+
93
+ error = message["error"]
94
+ raise "MCP request failed: #{error["message"] || error.inspect}" if error
95
+
96
+ return message["result"] || {}
97
+ end
98
+ end
99
+ rescue JSON::ParserError
100
+ raise "MCP server #{@command} returned invalid JSON"
101
+ rescue Timeout::Error
102
+ raise "MCP server #{@command} did not respond within #{@timeout_seconds} seconds"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -279,6 +279,34 @@ module Kward
279
279
  end
280
280
  end
281
281
 
282
+ def handle_editor_search_csi_u_key(sequence, enter: :confirm, restore_cursor_on_cancel: true)
283
+ result = case sequence[:code]
284
+ when 13
285
+ if enter == :repeat
286
+ shift_modifier?(sequence[:modifier]) ? editor_search_repeat(:backward) : editor_search_repeat(:forward)
287
+ else
288
+ editor_search_confirm
289
+ end
290
+ when 27
291
+ editor_search_cancel(restore_cursor: restore_cursor_on_cancel)
292
+ when 8, 127
293
+ editor_search_delete_character
294
+ else
295
+ normalized_code = ctrl_code(sequence[:code])
296
+ if ctrl_modifier?(sequence[:modifier]) && normalized_code == 103
297
+ shift_modifier?(sequence[:modifier]) ? editor_search_repeat(:backward) : editor_search_repeat(:forward)
298
+ elsif (text = csi_u_printable_text(sequence))
299
+ editor_search_append(text)
300
+ elsif csi_u_text_field?(sequence)
301
+ true
302
+ else
303
+ false
304
+ end
305
+ end
306
+ queue_pending_keys(sequence[:remaining]) if result != false && sequence[:remaining] && !sequence[:remaining].empty?
307
+ result
308
+ end
309
+
282
310
  def handle_editor_mouse_key(key)
283
311
  event = parse_editor_mouse_key(key)
284
312
  return false unless event
@@ -902,32 +930,40 @@ module Kward
902
930
 
903
931
  def editor_search_begin(direction = :forward)
904
932
  @editor_state.begin_search(direction)
933
+ ensure_editor_cursor_visible
905
934
  true
906
935
  end
907
936
 
908
937
  def editor_search_append(text)
909
938
  @editor_state.append_search(text)
939
+ ensure_editor_cursor_visible
910
940
  true
911
941
  end
912
942
 
913
943
  def editor_search_delete_character
914
944
  @editor_state.delete_search_character
945
+ ensure_editor_cursor_visible
915
946
  true
916
947
  end
917
948
 
918
- def editor_search_confirm
949
+ def editor_search_confirm(clear_highlights: true)
919
950
  @editor_state.confirm_search
951
+ @editor_state.clear_search_highlights if clear_highlights
952
+ ensure_editor_cursor_visible
920
953
  true
921
954
  end
922
955
 
923
- def editor_search_cancel
924
- @editor_state.cancel_search
956
+ def editor_search_cancel(restore_cursor: true)
957
+ @editor_state.cancel_search(restore_cursor: restore_cursor)
958
+ ensure_editor_cursor_visible
925
959
  true
926
960
  end
927
961
 
928
962
  def editor_search_repeat(direction = nil)
929
963
  direction ||= @editor_state.search_direction
930
964
  @editor_state.repeat_search(direction)
965
+ @editor_state.clear_search_highlights unless @editor_state.search_active
966
+ ensure_editor_cursor_visible
931
967
  true
932
968
  end
933
969
 
@@ -939,6 +975,31 @@ module Kward
939
975
  end
940
976
 
941
977
  @editor_state.repeat_search(direction, query)
978
+ @editor_state.clear_search_highlights
979
+ ensure_editor_cursor_visible
980
+ true
981
+ end
982
+
983
+ def ensure_editor_cursor_visible
984
+ return false unless @editor_state
985
+
986
+ content_width = [screen_width - 4, 1].max
987
+ visible_count = editor_visible_line_count
988
+ line_index, column = @editor_state.cursor_line_and_column
989
+ gutter_width = editor_line_number_gutter_width
990
+ text_width = editor_text_width(content_width, gutter_width)
991
+ sync_editor_wrap_state(text_width)
992
+
993
+ if current_editor_soft_wrap?
994
+ cursor_visual_row = editor_visual_row_for(line_index, column, text_width)
995
+ @editor_state.viewport_row = [[@editor_state.viewport_row, cursor_visual_row - visible_count + 1].max, cursor_visual_row].min
996
+ @editor_state.viewport_row = [@editor_state.viewport_row, 0].max
997
+ else
998
+ @editor_state.viewport_row = [[@editor_state.viewport_row, line_index - visible_count + 1].max, line_index].min
999
+ @editor_state.viewport_row = [@editor_state.viewport_row, 0].max
1000
+ @editor_state.viewport_column = [[@editor_state.viewport_column.to_i, column - text_width + 1].max, column].min
1001
+ @editor_state.viewport_column = [@editor_state.viewport_column, 0].max
1002
+ end
942
1003
  true
943
1004
  end
944
1005
 
@@ -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