kward 0.74.0 → 0.76.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +75 -10
  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/memory.md +1 -1
  11. data/doc/personas.md +2 -2
  12. data/doc/plugins.md +2 -1
  13. data/doc/releasing.md +7 -15
  14. data/doc/rpc.md +111 -16
  15. data/doc/session-management.md +9 -9
  16. data/doc/skills.md +199 -0
  17. data/doc/usage.md +7 -7
  18. data/lib/kward/agent.rb +77 -22
  19. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  20. data/lib/kward/auth/oauth_helpers.rb +105 -0
  21. data/lib/kward/auth/openai_oauth.rb +4 -86
  22. data/lib/kward/cli/commands.rb +9 -2
  23. data/lib/kward/cli/compaction.rb +7 -0
  24. data/lib/kward/cli/git.rb +30 -3
  25. data/lib/kward/cli/hook_commands.rb +258 -0
  26. data/lib/kward/cli/interactive_turn.rb +3 -0
  27. data/lib/kward/cli/memory_commands.rb +1 -5
  28. data/lib/kward/cli/plugins.rb +41 -1
  29. data/lib/kward/cli/prompt_interface.rb +28 -7
  30. data/lib/kward/cli/rendering.rb +8 -4
  31. data/lib/kward/cli/runtime_helpers.rb +44 -5
  32. data/lib/kward/cli/sessions.rb +13 -0
  33. data/lib/kward/cli/settings.rb +226 -152
  34. data/lib/kward/cli/slash_commands.rb +81 -42
  35. data/lib/kward/cli/tabs.rb +26 -6
  36. data/lib/kward/cli.rb +26 -3
  37. data/lib/kward/cli_transcript_formatter.rb +21 -18
  38. data/lib/kward/compaction/token_estimator.rb +112 -0
  39. data/lib/kward/compactor.rb +42 -116
  40. data/lib/kward/config_files.rb +120 -25
  41. data/lib/kward/deep_copy.rb +42 -0
  42. data/lib/kward/diff_view_mode.rb +36 -0
  43. data/lib/kward/events.rb +2 -0
  44. data/lib/kward/export_path.rb +2 -6
  45. data/lib/kward/frontmatter.rb +43 -0
  46. data/lib/kward/hooks/audit_log.rb +121 -0
  47. data/lib/kward/hooks/catalog.rb +110 -0
  48. data/lib/kward/hooks/command_handler.rb +66 -0
  49. data/lib/kward/hooks/config_loader.rb +104 -0
  50. data/lib/kward/hooks/decision.rb +102 -0
  51. data/lib/kward/hooks/event.rb +60 -0
  52. data/lib/kward/hooks/http_handler.rb +72 -0
  53. data/lib/kward/hooks/manager.rb +158 -0
  54. data/lib/kward/hooks/matcher.rb +75 -0
  55. data/lib/kward/hooks.rb +16 -0
  56. data/lib/kward/markdown_transcript.rb +4 -3
  57. data/lib/kward/mcp/client.rb +1 -1
  58. data/lib/kward/mcp/server_config.rb +7 -3
  59. data/lib/kward/mcp/stdio_transport.rb +7 -1
  60. data/lib/kward/memory/turn_context.rb +19 -0
  61. data/lib/kward/message_access.rb +2 -2
  62. data/lib/kward/message_text.rb +19 -0
  63. data/lib/kward/model/client.rb +23 -52
  64. data/lib/kward/model/context_usage.rb +10 -4
  65. data/lib/kward/model/copilot_models.rb +44 -0
  66. data/lib/kward/model/model_info.rb +38 -5
  67. data/lib/kward/model/payloads.rb +15 -17
  68. data/lib/kward/model/stream_parser.rb +183 -18
  69. data/lib/kward/pan/index.html.erb +1 -0
  70. data/lib/kward/pan/server.rb +53 -2
  71. data/lib/kward/path_guard.rb +13 -0
  72. data/lib/kward/plugin_registry.rb +88 -25
  73. data/lib/kward/prompt_interface/editor/controller.rb +60 -1
  74. data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
  75. data/lib/kward/prompt_interface/editor/state.rb +5 -1
  76. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  77. data/lib/kward/prompt_interface/transcript_renderer.rb +1 -1
  78. data/lib/kward/prompt_interface.rb +4 -1
  79. data/lib/kward/prompts/commands.rb +3 -3
  80. data/lib/kward/prompts.rb +7 -7
  81. data/lib/kward/rpc/mcp_status.rb +50 -0
  82. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  83. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  84. data/lib/kward/rpc/server.rb +95 -15
  85. data/lib/kward/rpc/session_manager.rb +260 -33
  86. data/lib/kward/rpc/session_metrics.rb +1 -1
  87. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  88. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  89. data/lib/kward/rpc/transcript_normalizer.rb +19 -20
  90. data/lib/kward/rpc/turn_context.rb +109 -0
  91. data/lib/kward/session_store.rb +6 -20
  92. data/lib/kward/session_tree_nodes.rb +98 -18
  93. data/lib/kward/session_tree_renderer.rb +6 -61
  94. data/lib/kward/skills/registry.rb +109 -17
  95. data/lib/kward/tools/mcp_tool.rb +4 -0
  96. data/lib/kward/tools/read_skill.rb +9 -4
  97. data/lib/kward/tools/registry.rb +266 -8
  98. data/lib/kward/tools/search/code.rb +4 -22
  99. data/lib/kward/tools/search/web.rb +28 -2
  100. data/lib/kward/update_check.rb +122 -0
  101. data/lib/kward/version.rb +1 -1
  102. data/lib/kward/workers/manager.rb +43 -3
  103. data/lib/kward/workspace.rb +3 -1
  104. data/templates/default/fulldoc/html/css/kward.css +22 -0
  105. data/templates/default/fulldoc/html/js/kward.js +2 -0
  106. data/templates/default/kward_navigation.rb +4 -2
  107. data/templates/default/layout/html/setup.rb +2 -0
  108. metadata +25 -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,12 +431,31 @@ 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)
393
454
  case event.class.name
394
455
  when "Kward::Events::ReasoningDelta"
395
456
  transcript_event("reasoning_delta", delta: event.delta)
457
+ when "Kward::Events::ReasoningBoundary"
458
+ transcript_event("reasoning_boundary")
396
459
  when "Kward::Events::AssistantDelta"
397
460
  transcript_event("assistant_delta", delta: event.delta)
398
461
  when "Kward::Events::AssistantMessage"
@@ -422,7 +485,7 @@ module Kward
422
485
  def transcript_event(type, payload)
423
486
  TranscriptEvent.new(
424
487
  type: type,
425
- payload: PluginRegistry.deep_freeze(PluginRegistry.deep_dup(payload))
488
+ payload: DeepCopy.freeze(DeepCopy.dup(payload))
426
489
  ).freeze
427
490
  end
428
491
  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
 
@@ -174,6 +174,7 @@ module Kward
174
174
 
175
175
  def editor_render_diff_line(line)
176
176
  text = line.to_s
177
+ return editor_render_side_by_side_diff_line(text) if @editor_state.side_by_side_diff_view?
177
178
  return colored(text, :green) if text.start_with?("+") && !text.start_with?("+++")
178
179
  return colored(text, :red) if text.start_with?("-") && !text.start_with?("---")
179
180
  return colored(text, :cyan) if text.start_with?("@@")
@@ -181,6 +182,16 @@ module Kward
181
182
  text
182
183
  end
183
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
+
184
195
  def editor_line_number_gutter_width
185
196
  [[@editor_state.lines.length.to_s.length, 4].max + 3, 1].max
186
197
  end
@@ -373,7 +373,11 @@ module Kward
373
373
  end
374
374
 
375
375
  def diff_view?
376
- @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
377
381
  end
378
382
 
379
383
  def modern?
@@ -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]}"
@@ -15,7 +15,7 @@ module Kward
15
15
  @stream_state.start_block(label)
16
16
  end
17
17
  write_transcript_text_locked(delta) unless delta.empty?
18
- write_transcript_text_locked("\n") if finish && @stream_state.block
18
+ write_transcript_text_locked("\n") if finish && @stream_state.block && !@transcript_buffer.end_with?("\n")
19
19
  @stream_state.finish_block if finish
20
20
  restore_composer_cursor_locked unless @restoring_transcript
21
21
  end
@@ -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