kward 0.73.0 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/Gemfile.lock +2 -2
- data/doc/configuration.md +14 -1
- data/doc/mcp.md +72 -0
- data/doc/rpc.md +1 -0
- data/doc/usage.md +7 -0
- data/lib/kward/cli/commands.rb +3 -0
- data/lib/kward/cli/doctor.rb +18 -10
- data/lib/kward/cli/interactive_turn.rb +9 -3
- data/lib/kward/cli/tabs.rb +6 -9
- data/lib/kward/cli.rb +38 -1
- data/lib/kward/config_files.rb +35 -2
- data/lib/kward/mcp/client.rb +56 -0
- data/lib/kward/mcp/server_config.rb +55 -0
- data/lib/kward/mcp/stdio_transport.rb +106 -0
- data/lib/kward/prompt_interface/editor/controller.rb +64 -3
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
- data/lib/kward/prompt_interface/editor/renderer.rb +16 -0
- data/lib/kward/prompt_interface/editor/search.rb +70 -13
- data/lib/kward/prompt_interface/editor/state.rb +67 -12
- data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -1
- data/lib/kward/prompt_interface/selection_prompt.rb +9 -0
- data/lib/kward/rpc/server.rb +1 -0
- data/lib/kward/tools/mcp_tool.rb +118 -0
- data/lib/kward/tools/registry.rb +32 -2
- data/lib/kward/version.rb +1 -1
- metadata +6 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|