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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +75 -10
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/memory.md +1 -1
- data/doc/personas.md +2 -2
- data/doc/plugins.md +2 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +111 -16
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +7 -7
- data/lib/kward/agent.rb +77 -22
- data/lib/kward/auth/anthropic_oauth.rb +5 -87
- data/lib/kward/auth/oauth_helpers.rb +105 -0
- data/lib/kward/auth/openai_oauth.rb +4 -86
- data/lib/kward/cli/commands.rb +9 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/interactive_turn.rb +3 -0
- data/lib/kward/cli/memory_commands.rb +1 -5
- data/lib/kward/cli/plugins.rb +41 -1
- data/lib/kward/cli/prompt_interface.rb +28 -7
- data/lib/kward/cli/rendering.rb +8 -4
- data/lib/kward/cli/runtime_helpers.rb +44 -5
- data/lib/kward/cli/sessions.rb +13 -0
- data/lib/kward/cli/settings.rb +226 -152
- data/lib/kward/cli/slash_commands.rb +81 -42
- data/lib/kward/cli/tabs.rb +26 -6
- data/lib/kward/cli.rb +26 -3
- data/lib/kward/cli_transcript_formatter.rb +21 -18
- data/lib/kward/compaction/token_estimator.rb +112 -0
- data/lib/kward/compactor.rb +42 -116
- data/lib/kward/config_files.rb +120 -25
- data/lib/kward/deep_copy.rb +42 -0
- data/lib/kward/diff_view_mode.rb +36 -0
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -6
- data/lib/kward/frontmatter.rb +43 -0
- data/lib/kward/hooks/audit_log.rb +121 -0
- data/lib/kward/hooks/catalog.rb +110 -0
- data/lib/kward/hooks/command_handler.rb +66 -0
- data/lib/kward/hooks/config_loader.rb +104 -0
- data/lib/kward/hooks/decision.rb +102 -0
- data/lib/kward/hooks/event.rb +60 -0
- data/lib/kward/hooks/http_handler.rb +72 -0
- data/lib/kward/hooks/manager.rb +158 -0
- data/lib/kward/hooks/matcher.rb +75 -0
- data/lib/kward/hooks.rb +16 -0
- data/lib/kward/markdown_transcript.rb +4 -3
- data/lib/kward/mcp/client.rb +1 -1
- data/lib/kward/mcp/server_config.rb +7 -3
- data/lib/kward/mcp/stdio_transport.rb +7 -1
- data/lib/kward/memory/turn_context.rb +19 -0
- data/lib/kward/message_access.rb +2 -2
- data/lib/kward/message_text.rb +19 -0
- data/lib/kward/model/client.rb +23 -52
- data/lib/kward/model/context_usage.rb +10 -4
- data/lib/kward/model/copilot_models.rb +44 -0
- data/lib/kward/model/model_info.rb +38 -5
- data/lib/kward/model/payloads.rb +15 -17
- data/lib/kward/model/stream_parser.rb +183 -18
- data/lib/kward/pan/index.html.erb +1 -0
- data/lib/kward/pan/server.rb +53 -2
- data/lib/kward/path_guard.rb +13 -0
- data/lib/kward/plugin_registry.rb +88 -25
- data/lib/kward/prompt_interface/editor/controller.rb +60 -1
- data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
- data/lib/kward/prompt_interface/editor/state.rb +5 -1
- data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
- data/lib/kward/prompt_interface/transcript_renderer.rb +1 -1
- data/lib/kward/prompt_interface.rb +4 -1
- data/lib/kward/prompts/commands.rb +3 -3
- data/lib/kward/prompts.rb +7 -7
- data/lib/kward/rpc/mcp_status.rb +50 -0
- data/lib/kward/rpc/prompt_bridge.rb +38 -4
- data/lib/kward/rpc/runtime_payloads.rb +1 -1
- data/lib/kward/rpc/server.rb +95 -15
- data/lib/kward/rpc/session_manager.rb +260 -33
- data/lib/kward/rpc/session_metrics.rb +1 -1
- data/lib/kward/rpc/session_tree_rows.rb +13 -64
- data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
- data/lib/kward/rpc/transcript_normalizer.rb +19 -20
- data/lib/kward/rpc/turn_context.rb +109 -0
- data/lib/kward/session_store.rb +6 -20
- data/lib/kward/session_tree_nodes.rb +98 -18
- data/lib/kward/session_tree_renderer.rb +6 -61
- data/lib/kward/skills/registry.rb +109 -17
- data/lib/kward/tools/mcp_tool.rb +4 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +266 -8
- data/lib/kward/tools/search/code.rb +4 -22
- data/lib/kward/tools/search/web.rb +28 -2
- data/lib/kward/update_check.rb +122 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/manager.rb +43 -3
- data/lib/kward/workspace.rb +3 -1
- data/templates/default/fulldoc/html/css/kward.css +22 -0
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +4 -2
- data/templates/default/layout/html/setup.rb +2 -0
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
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} ·
|
|
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: "
|
|
12
|
-
{ name: "resume", description: "Alias for /
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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] =
|
|
79
|
+
@answers[request_id] = nil
|
|
50
80
|
@condition.broadcast
|
|
51
81
|
end
|
|
52
82
|
end
|
|
53
83
|
|
|
54
|
-
|
|
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] =
|
|
92
|
+
@answers[request_id] = value
|
|
61
93
|
@condition.broadcast
|
|
62
94
|
end
|
|
63
95
|
end
|
|
64
96
|
|
|
65
|
-
|
|
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?
|