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
@@ -88,10 +88,26 @@ module Kward
88
88
  visible = line.to_s[column_offset.to_i, text_width].to_s
89
89
  rendered = editor_render_visible_line(visible, line_index)
90
90
  line_start = @editor_state.line_start_offset(line_index)
91
+ rendered = editor_overlay_search_matches(rendered, line_start, column_offset, visible.length)
91
92
  rendered = editor_overlay_line_selections(rendered, line_start, column_offset, visible.length)
92
93
  editor_overlay_secondary_cursors(rendered, line_start, column_offset, visible.length, text_width)
93
94
  end
94
95
 
96
+ def editor_overlay_search_matches(rendered, line_start, column_offset, visible_length)
97
+ ranges = @editor_state.search_match_ranges
98
+ return rendered if ranges.empty?
99
+
100
+ visible_offset = line_start + column_offset.to_i
101
+ match_ranges = ranges.filter_map do |range|
102
+ match_start = [range[0] - visible_offset, 0].max
103
+ match_end = [range[1] - visible_offset, visible_length].min
104
+ [match_start, match_end] if match_start < match_end
105
+ end
106
+ return rendered if match_ranges.empty?
107
+
108
+ editor_overlay_selection(rendered, match_ranges)
109
+ end
110
+
95
111
  def editor_overlay_line_selections(rendered, line_start, column_offset, visible_length)
96
112
  ranges = @editor_state.selection_ranges
97
113
  return rendered if ranges.empty?
@@ -158,6 +174,7 @@ module Kward
158
174
 
159
175
  def editor_render_diff_line(line)
160
176
  text = line.to_s
177
+ return editor_render_side_by_side_diff_line(text) if @editor_state.side_by_side_diff_view?
161
178
  return colored(text, :green) if text.start_with?("+") && !text.start_with?("+++")
162
179
  return colored(text, :red) if text.start_with?("-") && !text.start_with?("---")
163
180
  return colored(text, :cyan) if text.start_with?("@@")
@@ -165,6 +182,16 @@ module Kward
165
182
  text
166
183
  end
167
184
 
185
+ def editor_render_side_by_side_diff_line(text)
186
+ return colored(text, :cyan) if text.start_with?("@@")
187
+ return text unless text.include?(" │ ")
188
+
189
+ left, right = text.split(" │ ", 2)
190
+ rendered_left = left.start_with?("-") ? colored(left, :red) : left
191
+ rendered_right = right.start_with?("+") ? colored(right, :green) : right
192
+ "#{rendered_left} │ #{rendered_right}"
193
+ end
194
+
168
195
  def editor_line_number_gutter_width
169
196
  [[@editor_state.lines.length.to_s.length, 4].max + 3, 1].max
170
197
  end
@@ -6,44 +6,82 @@ module Kward
6
6
  class EditorSearch
7
7
  attr_reader :query, :direction
8
8
 
9
+ def self.match_ranges(text, query, base_offset: 0)
10
+ query = query.to_s
11
+ return [] if query.empty?
12
+
13
+ haystack, needle = normalized_pair(text.to_s, query)
14
+ ranges = []
15
+ start = 0
16
+ while (index = haystack.index(needle, start))
17
+ ranges << [base_offset + index, base_offset + index + query.length]
18
+ start = index + [query.length, 1].max
19
+ end
20
+ ranges
21
+ end
22
+
23
+ def self.normalized_pair(buffer, query)
24
+ return [buffer, query] if case_sensitive?(query)
25
+
26
+ [buffer.downcase, query.downcase]
27
+ end
28
+
29
+ def self.case_sensitive?(query)
30
+ query.to_s.match?(/[[:upper:]]/)
31
+ end
32
+
9
33
  def initialize(direction: :forward)
10
34
  @active = false
11
35
  @query = +""
12
36
  @direction = direction
37
+ @origin_cursor = nil
38
+ @current_match = nil
13
39
  end
14
40
 
15
41
  def active?
16
42
  @active == true
17
43
  end
18
44
 
19
- def begin(direction = :forward)
45
+ def begin(direction = :forward, cursor: nil)
20
46
  @active = true
21
47
  @direction = direction
22
48
  @query = +""
49
+ @origin_cursor = cursor
50
+ @current_match = nil
23
51
  status_prefix
24
52
  end
25
53
 
26
- def cancel
54
+ def cancel(restore_cursor: true)
27
55
  @active = false
28
- "Search cancelled"
56
+ cursor = @origin_cursor if restore_cursor
57
+ @origin_cursor = nil
58
+ @current_match = nil
59
+ { cursor: cursor, status: "Search cancelled", found: false }
29
60
  end
30
61
 
31
- def append(text)
62
+ def append(text, buffer:, cursor:)
32
63
  @query << text.to_s
33
- "#{status_prefix} #{@query}"
64
+ live_result(buffer: buffer, cursor: cursor)
34
65
  end
35
66
 
36
- def delete_character
67
+ def delete_character(buffer:, cursor:)
37
68
  @query = @query[0...-1].to_s
38
- "#{status_prefix} #{@query}"
69
+ live_result(buffer: buffer, cursor: cursor)
39
70
  end
40
71
 
41
72
  def confirm(buffer:, cursor:)
42
73
  confirmed_query = @query.to_s
43
74
  @active = false
75
+ @origin_cursor = nil
44
76
  return { status: "Search cancelled", found: false } if confirmed_query.empty?
45
77
 
78
+ if @current_match
79
+ return { cursor: @current_match, status: "Found: #{confirmed_query}", found: true }
80
+ end
81
+
46
82
  repeat(buffer: buffer, cursor: cursor, direction: @direction, query: confirmed_query)
83
+ ensure
84
+ @current_match = nil
47
85
  end
48
86
 
49
87
  def repeat(buffer:, cursor:, direction: @direction, query: @query)
@@ -52,12 +90,7 @@ module Kward
52
90
 
53
91
  @query = query
54
92
  @direction = direction
55
- index = if direction == :backward
56
- search_from = cursor.positive? ? cursor - 1 : buffer.length
57
- buffer.rindex(query, search_from) || buffer.rindex(query)
58
- else
59
- buffer.index(query, cursor + 1) || buffer.index(query)
60
- end
93
+ index = find_match(buffer, query, cursor, direction)
61
94
 
62
95
  if index
63
96
  { cursor: index, status: "Found: #{query}", found: true }
@@ -68,6 +101,30 @@ module Kward
68
101
 
69
102
  private
70
103
 
104
+ def live_result(buffer:, cursor:)
105
+ return { cursor: @origin_cursor, status: status_prefix, found: false } if @query.empty?
106
+
107
+ @origin_cursor = cursor if @origin_cursor.nil?
108
+ index = find_match(buffer, @query, @origin_cursor, @direction)
109
+ @current_match = index
110
+ if index
111
+ { cursor: index, status: "#{status_prefix} #{@query}", found: true }
112
+ else
113
+ { cursor: @origin_cursor, status: "No match: #{@query}", found: false }
114
+ end
115
+ end
116
+
117
+ def find_match(buffer, query, cursor, direction)
118
+ haystack, needle = self.class.normalized_pair(buffer.to_s, query.to_s)
119
+ cursor = cursor.to_i
120
+ if direction == :backward
121
+ search_from = cursor.positive? ? cursor - 1 : haystack.length
122
+ haystack.rindex(needle, search_from) || haystack.rindex(needle)
123
+ else
124
+ haystack.index(needle, cursor + 1) || haystack.index(needle)
125
+ end
126
+ end
127
+
71
128
  def status_prefix
72
129
  @direction == :backward ? "Search backward:" : "Search:"
73
130
  end
@@ -19,6 +19,7 @@ module Kward
19
19
  attr_reader :path, :display_path, :language, :original_content, :original_digest, :original_mtime, :original_size
20
20
  attr_reader :buffer, :undo_stack, :redo_stack, :kill_buffer, :kill_ring, :last_yank_range, :last_yank_index
21
21
  attr_accessor :viewport_row, :viewport_column, :status, :overwrite_confirmed, :quit_confirmed, :search_active, :search_query, :search_direction, :new_file, :editor_mode, :emacs_pending, :readonly, :diff_view
22
+ attr_reader :search_match_ranges
22
23
 
23
24
  def initialize(path:, content:, new_file: false, editor_mode: "modern", readonly: false, diff_view: false, virtual: false, display_path: nil, language: nil)
24
25
  @path = virtual ? nil : path.to_s
@@ -45,6 +46,7 @@ module Kward
45
46
  @search_active = @search.active?
46
47
  @search_query = @search.query
47
48
  @search_direction = @search.direction
49
+ @search_match_ranges = []
48
50
  @kill_state = EditorKillRing.new
49
51
  @kill_buffer = @kill_state.kill_buffer
50
52
  @selections = EditorSelections.new(cursor: @cursor, buffer_length: @buffer.length)
@@ -80,6 +82,7 @@ module Kward
80
82
  @search_active = other.search_active
81
83
  @search_query = other.search_query.dup
82
84
  @search_direction = other.search_direction
85
+ @search_match_ranges = other.search_match_ranges.map(&:dup)
83
86
  @kill_state = EditorKillRing.new(
84
87
  kill_buffer: other.kill_buffer.dup,
85
88
  kill_ring: other.kill_ring.map(&:dup),
@@ -231,6 +234,51 @@ module Kward
231
234
  sync_vibe_state
232
235
  end
233
236
 
237
+ def vibe_register_types
238
+ @vibe_state.register_types
239
+ end
240
+
241
+ def vibe_register_types=(value)
242
+ @vibe_state.register_types = value
243
+ sync_vibe_state
244
+ end
245
+
246
+ def vibe_kill_linewise
247
+ @vibe_state.kill_linewise
248
+ end
249
+
250
+ def vibe_kill_linewise=(value)
251
+ @vibe_state.kill_linewise = value
252
+ sync_vibe_state
253
+ end
254
+
255
+ def vibe_previous_change_cursor
256
+ @vibe_state.previous_change_cursor
257
+ end
258
+
259
+ def vibe_previous_change_cursor=(value)
260
+ @vibe_state.previous_change_cursor = value
261
+ sync_vibe_state
262
+ end
263
+
264
+ def vibe_jump_back_list
265
+ @vibe_state.jump_back_list
266
+ end
267
+
268
+ def vibe_jump_back_list=(value)
269
+ @vibe_state.jump_back_list = value
270
+ sync_vibe_state
271
+ end
272
+
273
+ def vibe_jump_forward_list
274
+ @vibe_state.jump_forward_list
275
+ end
276
+
277
+ def vibe_jump_forward_list=(value)
278
+ @vibe_state.jump_forward_list = value
279
+ sync_vibe_state
280
+ end
281
+
234
282
  def vibe_macros
235
283
  @vibe_state.macros
236
284
  end
@@ -325,7 +373,11 @@ module Kward
325
373
  end
326
374
 
327
375
  def diff_view?
328
- @diff_view == true
376
+ !@diff_view.nil? && @diff_view != false
377
+ end
378
+
379
+ def side_by_side_diff_view?
380
+ @diff_view == DiffViewMode::SIDE_BY_SIDE
329
381
  end
330
382
 
331
383
  def modern?
@@ -372,7 +424,7 @@ module Kward
372
424
  end
373
425
 
374
426
  restore_editor_snapshot(snapshot)
375
- changed!(clear_selections: false)
427
+ changed!
376
428
  @status = "Undo"
377
429
  true
378
430
  end
@@ -385,7 +437,7 @@ module Kward
385
437
  end
386
438
 
387
439
  restore_editor_snapshot(snapshot)
388
- changed!(clear_selections: false)
440
+ changed!
389
441
  @status = "Redo"
390
442
  true
391
443
  end
@@ -909,27 +961,28 @@ module Kward
909
961
  end
910
962
 
911
963
  def begin_search(direction = :forward)
912
- @status = @search.begin(direction)
964
+ @status = @search.begin(direction, cursor: @cursor)
913
965
  sync_search_state
914
966
  true
915
967
  end
916
968
 
917
- def cancel_search
918
- @status = @search.cancel
919
- sync_search_state
969
+ def cancel_search(restore_cursor: true)
970
+ apply_search_result(@search.cancel(restore_cursor: restore_cursor))
971
+ @search_match_ranges = []
920
972
  true
921
973
  end
922
974
 
923
- def append_search(text)
924
- @status = @search.append(text)
925
- sync_search_state
975
+ def clear_search_highlights
976
+ @search_match_ranges = []
926
977
  true
927
978
  end
928
979
 
980
+ def append_search(text)
981
+ apply_search_result(@search.append(text, buffer: @buffer, cursor: @cursor))
982
+ end
983
+
929
984
  def delete_search_character
930
- @status = @search.delete_character
931
- sync_search_state
932
- true
985
+ apply_search_result(@search.delete_character(buffer: @buffer, cursor: @cursor))
933
986
  end
934
987
 
935
988
  def confirm_search
@@ -1224,6 +1277,7 @@ module Kward
1224
1277
  @search_active = @search.active?
1225
1278
  @search_query = @search.query
1226
1279
  @search_direction = @search.direction
1280
+ @search_match_ranges = EditorSearch.match_ranges(@buffer, @search_query)
1227
1281
  end
1228
1282
 
1229
1283
  def sync_vibe_state
@@ -1236,6 +1290,11 @@ module Kward
1236
1290
  @vibe_visual_block_insert = @vibe_state.visual_block_insert
1237
1291
  @vibe_marks = @vibe_state.marks
1238
1292
  @vibe_registers = @vibe_state.registers
1293
+ @vibe_register_types = @vibe_state.register_types
1294
+ @vibe_kill_linewise = @vibe_state.kill_linewise
1295
+ @vibe_previous_change_cursor = @vibe_state.previous_change_cursor
1296
+ @vibe_jump_back_list = @vibe_state.jump_back_list
1297
+ @vibe_jump_forward_list = @vibe_state.jump_forward_list
1239
1298
  @vibe_macros = @vibe_state.macros
1240
1299
  @vibe_recording_macro = @vibe_state.recording_macro
1241
1300
  @vibe_last_macro = @vibe_state.last_macro
@@ -6,7 +6,8 @@ module Kward
6
6
  class VibeEditorState
7
7
  attr_accessor :mode, :pending, :command, :last_change, :last_find
8
8
  attr_accessor :last_visual_selection, :visual_block_insert
9
- attr_accessor :marks, :registers, :macros, :recording_macro, :last_macro
9
+ attr_accessor :marks, :registers, :register_types, :macros, :recording_macro, :last_macro
10
+ attr_accessor :kill_linewise, :previous_change_cursor, :jump_back_list, :jump_forward_list
10
11
 
11
12
  def initialize(editor_mode: "modern")
12
13
  @mode = editor_mode == "vibe" ? "normal" : nil
@@ -18,6 +19,11 @@ module Kward
18
19
  @visual_block_insert = nil
19
20
  @marks = {}
20
21
  @registers = {}
22
+ @register_types = {}
23
+ @kill_linewise = false
24
+ @previous_change_cursor = nil
25
+ @jump_back_list = []
26
+ @jump_forward_list = []
21
27
  @macros = {}
22
28
  @recording_macro = nil
23
29
  @last_macro = nil
@@ -34,6 +40,11 @@ module Kward
34
40
  state.visual_block_insert = other.visual_block_insert&.dup
35
41
  state.marks = other.marks.transform_values(&:dup)
36
42
  state.registers = other.registers.transform_values(&:dup)
43
+ state.register_types = other.register_types.transform_values(&:dup)
44
+ state.kill_linewise = other.kill_linewise
45
+ state.previous_change_cursor = other.previous_change_cursor
46
+ state.jump_back_list = other.jump_back_list.map(&:dup)
47
+ state.jump_forward_list = other.jump_forward_list.map(&:dup)
37
48
  state.macros = other.macros.transform_values(&:dup)
38
49
  state.recording_macro = other.recording_macro
39
50
  state.last_macro = other.last_macro
@@ -73,6 +73,8 @@ module Kward
73
73
  queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
74
74
 
75
75
  case code
76
+ when 9
77
+ modified_tab_sequence?(sequence) ? true : select_toggle_search
76
78
  when 13
77
79
  select_input_active? ? select_input_action_result : select_current_choice
78
80
  when 27
@@ -501,25 +503,43 @@ module Kward
501
503
 
502
504
  def select_typed_key(key)
503
505
  return select_insert_key(key) if select_input_active?
504
- return select_begin_search if key == "/" && !select_search_active?
506
+ return select_toggle_search if select_search_toggle_key?(key)
505
507
  return select_action_key(key) unless select_search_active?
506
508
 
507
509
  select_insert_key(key)
508
510
  end
509
511
 
512
+ def select_search_toggle_key?(key)
513
+ TerminalKeys::TAB.include?(key)
514
+ end
515
+
516
+ def modified_tab_sequence?(sequence)
517
+ modifier = sequence[:modifier]
518
+ modifier && modifier != 1
519
+ end
520
+
521
+ def select_toggle_search
522
+ select_search_active? ? select_pause_search : select_begin_search
523
+ end
524
+
510
525
  def select_begin_search
511
526
  return unless @select_state
512
527
 
513
528
  @select_state[:search_active] = true
514
- self.composer_input = ""
515
- self.composer_cursor = 0
516
529
  true
517
530
  end
518
531
 
519
- def select_cancel_search
532
+ def select_pause_search
520
533
  return unless @select_state
521
534
 
522
535
  @select_state[:search_active] = false
536
+ true
537
+ end
538
+
539
+ def select_cancel_search
540
+ return unless @select_state
541
+
542
+ select_pause_search
523
543
  self.composer_input = ""
524
544
  self.composer_cursor = 0
525
545
  @select_state[:selection_index] = 0
@@ -607,7 +627,7 @@ module Kward
607
627
 
608
628
  def selection_matches
609
629
  choices = @select_state ? @select_state[:choices] : []
610
- filter = select_search_active? ? composer_input.downcase.strip : ""
630
+ filter = (select_search_active? || !composer_input.strip.empty?) ? composer_input.downcase.strip : ""
611
631
  matches = filter.empty? ? choices : choices.select { |choice| choice.downcase.include?(filter) }
612
632
  clamp_selection_index(matches.length)
613
633
  matches
@@ -666,7 +686,7 @@ module Kward
666
686
  return "Renaming · Enter save · Esc cancel" if select_input_active?
667
687
 
668
688
  text = "↑/↓ select · Enter open"
669
- text = "#{text} · / search" unless select_search_active?
689
+ text = "#{text} · Tab search" unless select_search_active?
670
690
  action_keys = @select_state ? @select_state[:action_keys].to_h : {}
671
691
  action_keys.each do |key, action|
672
692
  text = "#{text} · #{key} #{action[:action]}"
@@ -12,6 +12,7 @@ require_relative "ansi"
12
12
  require_relative "terminal_sequences"
13
13
  require_relative "terminal_keys"
14
14
  require_relative "editor_mode"
15
+ require_relative "diff_view_mode"
15
16
  require_relative "prompt_interface/banner"
16
17
  require_relative "prompt_interface/composer_state"
17
18
  require_relative "prompt_interface/editor/state"
@@ -125,7 +126,7 @@ module Kward
125
126
  end
126
127
  end
127
128
 
128
- def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil)
129
+ def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
129
130
  @input_io = input
130
131
  @output_io = output
131
132
  @reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
@@ -206,6 +207,8 @@ module Kward
206
207
  @editor_bar_cursor_source = editor_bar_cursor_source
207
208
  @editor_line_numbers = normalize_editor_line_numbers(editor_line_numbers)
208
209
  @editor_line_numbers_source = editor_line_numbers_source
210
+ @diff_view = DiffViewMode.normalize(diff_view)
211
+ @diff_view_source = diff_view_source
209
212
  end
210
213
 
211
214
  def start(render: true)
@@ -8,9 +8,8 @@ module Kward
8
8
  { name: "exit", description: "Exit the interactive session.", argument_hint: "" },
9
9
  { name: "quit", description: "Exit the interactive session.", argument_hint: "" },
10
10
  { name: "new", description: "Start a new session.", argument_hint: "" },
11
- { name: "sessions", description: "Open the saved sessions picker.", argument_hint: "[path]" },
12
- { name: "resume", description: "Alias for /sessions.", argument_hint: "[path]" },
13
- { name: "name", description: "Name or clear the current session.", argument_hint: "[name]" },
11
+ { name: "session", description: "Open saved sessions or name the current session.", argument_hint: "[path]|name [name]" },
12
+ { name: "resume", description: "Alias for /session.", argument_hint: "[path]" },
14
13
  { name: "rename", description: "Rename the current session.", argument_hint: "<name>" },
15
14
  { name: "clone", description: "Clone the current session.", argument_hint: "" },
16
15
  { name: "fork", description: "Fork from an earlier prompt into a new session.", argument_hint: "" },
@@ -36,6 +35,7 @@ module Kward
36
35
  { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name]" },
37
36
  { name: "status", description: "Show the current status message.", argument_hint: "" },
38
37
  { name: "stats", description: "Show telemetry logging stats.", argument_hint: "[range]" },
38
+ { name: "hooks", description: "Inspect lifecycle hooks.", argument_hint: "[list|events|logs|doctor|trust|untrust]" },
39
39
  { name: "memory", description: "Inspect and manage Kward memory.", argument_hint: "[enable|disable|auto-summary|core|add|list|forget|promote|relax|inspect|why|summarize]" }
40
40
  ].freeze
41
41
  BUILTIN_RESERVED_COMMAND_NAMES = BUILTIN_COMMANDS.map { |command| command[:name] }.freeze
data/lib/kward/prompts.rb CHANGED
@@ -25,7 +25,8 @@ module Kward
25
25
  sections << prompt_section("Persona", persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now))
26
26
  sections << prompt_section("Plugin context", plugin_context) unless plugin_context.to_s.empty?
27
27
  end
28
- sections << prompt_section("Configured skills", skills_prompt, source: ConfigFiles.skills.empty? ? nil : File.join(ConfigFiles.config_dir, "skills"))
28
+ skills = ConfigFiles.skills(workspace_root: workspace_root)
29
+ sections << prompt_section("Configured skills", skills_prompt(skills), source: skills.empty? ? nil : File.join(ConfigFiles.config_dir, "skills"))
29
30
  sections << prompt_section(workspace_agents_context_label(workspace_root), workspace_agents_context(workspace_root), source: ConfigFiles.workspace_agents_file?(workspace_root) ? ConfigFiles.workspace_agents_path(workspace_root) : nil)
30
31
  sections.compact
31
32
  end
@@ -97,18 +98,17 @@ module Kward
97
98
  { label: label, content: content, source: source }
98
99
  end
99
100
 
100
- def skills_prompt
101
- skills = ConfigFiles.skills
101
+ def skills_prompt(skills = ConfigFiles.skills)
102
102
  return nil if skills.empty?
103
103
 
104
104
  lines = [
105
- "Configured skills are available in the Kward config directory.",
106
- "When a task matches a skill, use read_skill to load its instructions before proceeding.",
105
+ "Agent Skills are available.",
106
+ "When a task matches a skill's description, use read_skill to load its instructions before proceeding.",
107
+ "Use read_skill with a relative path to read referenced files inside a skill directory.",
107
108
  "Available skills:"
108
109
  ]
109
110
  skills.each do |skill|
110
- description = skill.description.empty? ? "No description provided." : skill.description
111
- lines << "- #{skill.name}: #{description}"
111
+ lines << "- #{skill.name}: #{skill.description}"
112
112
  end
113
113
  lines.join("\n")
114
114
  end
@@ -0,0 +1,50 @@
1
+ require_relative "../mcp/server_config"
2
+ require_relative "redactor"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # JSON-RPC backend namespace used by UI clients.
7
+ module RPC
8
+ # Builds RPC-visible MCP server status payloads.
9
+ class MCPStatus
10
+ def initialize(config_manager:)
11
+ @config_manager = config_manager
12
+ end
13
+
14
+ def to_h
15
+ config = @config_manager.read(redacted: false)
16
+ clients = MCP::ServerConfig.clients_from_config(config).to_h { |client| [client.name, client] }
17
+ servers = MCP::ServerConfig.configured_servers(config).map do |name, settings|
18
+ server_status(name, settings, clients[name.to_s])
19
+ end
20
+ { servers: servers }
21
+ end
22
+
23
+ private
24
+
25
+ def server_status(name, settings, client)
26
+ base = { name: name.to_s, transport: "stdio", toolCount: 0 }
27
+ unless client
28
+ return base.merge(status: "unavailable", error: "command not configured") if settings["command"].to_s.empty?
29
+
30
+ return base.merge(status: "unavailable", error: "unsupported MCP server configuration")
31
+ end
32
+
33
+ tools = client.list_tools
34
+ base.merge(status: "available", toolCount: tools.length)
35
+ rescue StandardError => e
36
+ base.merge(status: "unavailable", toolCount: 0, error: redacted_error(e.message, client))
37
+ ensure
38
+ client&.close
39
+ end
40
+
41
+ def redacted_error(message, client)
42
+ text = Redactor.redact_string(message.to_s)
43
+ command = client&.transport&.respond_to?(:command) ? client.transport.command.to_s : ""
44
+ return text if command.empty?
45
+
46
+ text.gsub(command, File.basename(command))
47
+ end
48
+ end
49
+ end
50
+ end
@@ -17,6 +17,32 @@ module Kward
17
17
  @pending_requests = {}
18
18
  end
19
19
 
20
+ def request_tool_approval(tool_call_id:, tool_name:, args:, cancellation: nil)
21
+ request_id = SecureRandom.uuid
22
+ @mutex.synchronize { @pending_requests[request_id] = true }
23
+ cancellation&.on_cancel { cancel_request(request_id) }
24
+ unless cancellation&.cancelled?
25
+ @server.notify("tool/approvalRequested", {
26
+ sessionId: @session_id,
27
+ approvalRequestId: request_id,
28
+ toolCallId: tool_call_id,
29
+ toolName: tool_name,
30
+ args: args
31
+ })
32
+ end
33
+
34
+ @mutex.synchronize do
35
+ @condition.wait(@mutex) until @answers.key?(request_id)
36
+ answer = @answers.delete(request_id)
37
+ @pending_requests.delete(request_id)
38
+ approved_answer?(answer)
39
+ end
40
+ end
41
+
42
+ def answer_tool_approval(request_id, approved:)
43
+ answer_raw(request_id, !!approved)
44
+ end
45
+
20
46
  def ask_user_question(questions, cancellation: nil)
21
47
  questions = validate_questions(questions)
22
48
  request_id = SecureRandom.uuid
@@ -41,28 +67,36 @@ module Kward
41
67
  end
42
68
 
43
69
  def answer(request_id, answers)
70
+ answer_raw(request_id, normalize_answers(answers))
71
+ end
72
+
73
+ def cancel_request(request_id)
44
74
  @mutex.synchronize do
45
75
  request_id = request_id.to_s
46
76
  return unless @pending_requests.key?(request_id)
47
77
  return if @answers.key?(request_id)
48
78
 
49
- @answers[request_id] = normalize_answers(answers)
79
+ @answers[request_id] = nil
50
80
  @condition.broadcast
51
81
  end
52
82
  end
53
83
 
54
- def cancel_request(request_id)
84
+ private
85
+
86
+ def answer_raw(request_id, value)
55
87
  @mutex.synchronize do
56
88
  request_id = request_id.to_s
57
89
  return unless @pending_requests.key?(request_id)
58
90
  return if @answers.key?(request_id)
59
91
 
60
- @answers[request_id] = nil
92
+ @answers[request_id] = value
61
93
  @condition.broadcast
62
94
  end
63
95
  end
64
96
 
65
- private
97
+ def approved_answer?(answer)
98
+ answer == true
99
+ end
66
100
 
67
101
  def normalize_answers(answers)
68
102
  return nil if answers.nil?
@@ -2,7 +2,7 @@
2
2
  module Kward
3
3
  # JSON-RPC backend namespace used by UI clients.
4
4
  module RPC
5
- # Builders for Tauren-compatible runtime state payloads.
5
+ # Builders for frontend-neutral runtime state payloads.
6
6
  module RuntimePayloads
7
7
  module_function
8
8