kward 0.70.0 → 0.72.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/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +89 -3
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +34 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +52 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +58 -23
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +102 -13
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +83 -0
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +74 -3
- data/doc/releasing.md +45 -8
- data/doc/rpc.md +77 -15
- data/doc/session-management.md +254 -0
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +60 -15
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +144 -0
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +41 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +111 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +262 -13
- data/lib/kward/cli/settings.rb +216 -37
- data/lib/kward/cli/slash_commands.rb +439 -8
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +171 -26
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +125 -5
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +59 -22
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -16
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +108 -1
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +124 -83
- data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +299 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +416 -43
- data/lib/kward/prompt_interface/layout.rb +2 -2
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
- data/lib/kward/prompt_interface/question_prompt.rb +122 -82
- data/lib/kward/prompt_interface/runtime_state.rb +49 -1
- data/lib/kward/prompt_interface/screen.rb +17 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +307 -35
- data/lib/kward/prompts/commands.rb +7 -1
- data/lib/kward/prompts.rb +4 -2
- data/lib/kward/rpc/server.rb +45 -11
- data/lib/kward/rpc/session_manager.rb +52 -53
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +67 -4
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/telemetry/logger.rb +5 -3
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +92 -15
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +12 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +7 -0
- data/lib/kward/workspace.rb +154 -12
- data/templates/default/fulldoc/html/css/kward.css +362 -42
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +161 -2
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +102 -0
- data/templates/default/layout/html/layout.erb +43 -10
- data/templates/default/layout/html/setup.rb +39 -38
- metadata +65 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- data/lib/kward/resources/pixel_logo.rb +0 -232
|
@@ -7,6 +7,7 @@ module Kward
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def run_interactive_turn(agent, input, display_input: nil)
|
|
10
|
+
stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
|
|
10
11
|
prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
|
|
11
12
|
print_user_transcript(input, display_input: display_input) if prompt_interface?
|
|
12
13
|
return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
|
|
@@ -42,7 +43,7 @@ module Kward
|
|
|
42
43
|
|
|
43
44
|
while worker.alive?
|
|
44
45
|
begin
|
|
45
|
-
poll_result = collect_busy_input(queued_inputs, steering)
|
|
46
|
+
poll_result = collect_busy_input(queued_inputs, steering, agent)
|
|
46
47
|
sleep 0.01
|
|
47
48
|
rescue Interrupt
|
|
48
49
|
poll_result = PromptInterface::CANCEL_INPUT
|
|
@@ -52,7 +53,11 @@ module Kward
|
|
|
52
53
|
cancellation.cancel!
|
|
53
54
|
worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
|
|
54
55
|
end
|
|
55
|
-
|
|
56
|
+
if busy_replacement_agent?
|
|
57
|
+
discard_interactive_events(event_queue, markdown_chunks, stream_state)
|
|
58
|
+
else
|
|
59
|
+
drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
|
|
60
|
+
end
|
|
56
61
|
end
|
|
57
62
|
begin
|
|
58
63
|
worker.join
|
|
@@ -60,10 +65,14 @@ module Kward
|
|
|
60
65
|
error ||= e
|
|
61
66
|
end
|
|
62
67
|
drain_busy_input(queued_inputs, nil) unless cancelled
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
if busy_replacement_agent?
|
|
69
|
+
discard_interactive_events(event_queue, markdown_chunks, stream_state, force: true)
|
|
70
|
+
else
|
|
71
|
+
drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
|
|
72
|
+
end
|
|
73
|
+
raise error if error && !error.is_a?(Cancellation::CancelledError) && !busy_replacement_agent?
|
|
65
74
|
|
|
66
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || stream_state[:streamed] || answer.to_s.empty?
|
|
75
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || busy_replacement_agent? || stream_state[:streamed] || answer.to_s.empty?
|
|
67
76
|
persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
|
|
68
77
|
auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
|
|
69
78
|
queued_inputs
|
|
@@ -87,6 +96,21 @@ module Kward
|
|
|
87
96
|
flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
|
|
88
97
|
end
|
|
89
98
|
|
|
99
|
+
def discard_interactive_events(event_queue, markdown_chunks, stream_state, force: false)
|
|
100
|
+
drained = 0
|
|
101
|
+
loop do
|
|
102
|
+
break if !force && drained >= INTERACTIVE_EVENT_DRAIN_LIMIT
|
|
103
|
+
|
|
104
|
+
event_queue.pop(true)
|
|
105
|
+
drained += 1
|
|
106
|
+
rescue ThreadError
|
|
107
|
+
break
|
|
108
|
+
end
|
|
109
|
+
markdown_chunks.clear
|
|
110
|
+
finish_stream_block if stream_state[:stream_block_open]
|
|
111
|
+
stream_state[:stream_block_open] = false
|
|
112
|
+
end
|
|
113
|
+
|
|
90
114
|
def handle_interactive_event(event, markdown_chunks, stream_state)
|
|
91
115
|
case event
|
|
92
116
|
when Events::ReasoningDelta
|
|
@@ -154,13 +178,18 @@ module Kward
|
|
|
154
178
|
collect_busy_input(queued_inputs, nil)
|
|
155
179
|
end
|
|
156
180
|
|
|
157
|
-
def collect_busy_input(queued_inputs, steering)
|
|
181
|
+
def collect_busy_input(queued_inputs, steering, agent = nil)
|
|
158
182
|
return nil if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
|
|
159
183
|
|
|
160
184
|
poll_result = @prompt.poll_input
|
|
161
185
|
case poll_result
|
|
162
186
|
when String
|
|
163
|
-
if
|
|
187
|
+
return poll_result if handle_busy_worker_input(poll_result, agent, queued_inputs)
|
|
188
|
+
|
|
189
|
+
if slash_command_input?(poll_result)
|
|
190
|
+
queued_inputs << poll_result
|
|
191
|
+
@prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
192
|
+
elsif steering && !poll_result.strip.empty?
|
|
164
193
|
begin
|
|
165
194
|
steering.submit(poll_result)
|
|
166
195
|
@prompt.set_steered_count(1) if @prompt.respond_to?(:set_steered_count)
|
|
@@ -183,16 +212,51 @@ module Kward
|
|
|
183
212
|
drain_busy_input(queued_inputs, nil)
|
|
184
213
|
end
|
|
185
214
|
|
|
186
|
-
def drain_busy_input(queued_inputs, steering)
|
|
215
|
+
def drain_busy_input(queued_inputs, steering, agent = nil)
|
|
187
216
|
deadline = Time.now + 0.15
|
|
188
217
|
loop do
|
|
189
|
-
poll_result = collect_busy_input(queued_inputs, steering)
|
|
218
|
+
poll_result = collect_busy_input(queued_inputs, steering, agent)
|
|
190
219
|
break if Time.now > deadline && poll_result.nil?
|
|
191
220
|
|
|
192
221
|
sleep 0.01
|
|
193
222
|
end
|
|
194
223
|
end
|
|
195
224
|
|
|
225
|
+
def slash_command_input?(input)
|
|
226
|
+
input.to_s.strip.start_with?("/")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def handle_busy_worker_input(input, agent, queued_inputs)
|
|
230
|
+
return false unless agent
|
|
231
|
+
|
|
232
|
+
command = input.to_s.strip
|
|
233
|
+
return false unless command == "/workers" || command.start_with?("/workers ")
|
|
234
|
+
|
|
235
|
+
_handled, replacement_agent = handle_local_slash_command(command, agent, @session_store)
|
|
236
|
+
@busy_replacement_agent = replacement_agent if replacement_agent?(replacement_agent)
|
|
237
|
+
restore_busy_input_prompt
|
|
238
|
+
true
|
|
239
|
+
rescue StandardError => e
|
|
240
|
+
runtime_output("Error: #{e.message}")
|
|
241
|
+
restore_busy_input_prompt
|
|
242
|
+
true
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def replacement_agent?(object)
|
|
246
|
+
object.respond_to?(:conversation) && object.respond_to?(:ask)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def busy_replacement_agent?
|
|
250
|
+
replacement_agent?(@busy_replacement_agent)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def restore_busy_input_prompt
|
|
254
|
+
return unless @prompt.respond_to?(:begin_busy_input)
|
|
255
|
+
return if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
|
|
256
|
+
|
|
257
|
+
@prompt.begin_busy_input("You>")
|
|
258
|
+
end
|
|
259
|
+
|
|
196
260
|
def steering_supported?
|
|
197
261
|
@client.respond_to?(:supports_in_flight_steer?) && @client.supports_in_flight_steer?
|
|
198
262
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# OpenRouter cache management commands for the terminal CLI flow.
|
|
6
|
+
module OpenRouterCommands
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def handle_openrouter_command(arguments)
|
|
10
|
+
case arguments
|
|
11
|
+
when ["refresh"], ["--refresh"]
|
|
12
|
+
refresh_openrouter_models
|
|
13
|
+
when ["list"], ["--list"]
|
|
14
|
+
list_openrouter_models
|
|
15
|
+
else
|
|
16
|
+
raise ArgumentError, command_usage("openrouter")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def refresh_openrouter_models
|
|
21
|
+
cache = OpenRouterModelCache.new(api_key: configured_openrouter_api_key, path: openrouter_models_cache_path)
|
|
22
|
+
data = cache.refresh
|
|
23
|
+
count = Array(data["models"]).length
|
|
24
|
+
@client.reload_config if @client.respond_to?(:reload_config)
|
|
25
|
+
@prompt.say("Refreshed #{count} OpenRouter text model#{count == 1 ? "" : "s"} for this key.")
|
|
26
|
+
@prompt.say("Cached at: #{cache.path}")
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
warn e.message
|
|
29
|
+
exit 1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list_openrouter_models
|
|
33
|
+
cache = OpenRouterModelCache.new(api_key: configured_openrouter_api_key, path: openrouter_models_cache_path)
|
|
34
|
+
data = cache.read
|
|
35
|
+
unless data
|
|
36
|
+
@prompt.say("No OpenRouter model cache found. Run `kward openrouter refresh` first.")
|
|
37
|
+
return
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
models = Array(data["models"])
|
|
41
|
+
lines = ["OpenRouter models cached at #{data["refreshed_at"]}:"]
|
|
42
|
+
lines.concat(models.map { |model| model["id"].to_s }.reject(&:empty?))
|
|
43
|
+
@prompt.say(lines.join("\n"))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def configured_openrouter_api_key
|
|
47
|
+
ENV["OPENROUTER_API_KEY"].to_s.empty? ? ConfigFiles.config_value(ConfigFiles.read_config, "openrouter_api_key").to_s : ENV["OPENROUTER_API_KEY"].to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def openrouter_models_cache_path
|
|
51
|
+
File.join(File.dirname(ConfigFiles.config_path), "cache", "openrouter_models.json")
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/kward/cli/plugins.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Kward
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def prompt_templates
|
|
10
|
-
@prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands:
|
|
10
|
+
@prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands: builtin_slash_command_names)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def plugin_registry
|
|
@@ -22,6 +22,14 @@ module Kward
|
|
|
22
22
|
plugin_registry.command_for(command)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
def interactive_commands
|
|
26
|
+
plugin_registry.interactive_commands
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def interactive_command_for(command)
|
|
30
|
+
plugin_registry.interactive_command_for(command)
|
|
31
|
+
end
|
|
32
|
+
|
|
25
33
|
def reload_plugins(conversation)
|
|
26
34
|
@plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
|
|
27
35
|
conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
|
|
@@ -30,7 +38,34 @@ module Kward
|
|
|
30
38
|
end
|
|
31
39
|
|
|
32
40
|
def reserved_slash_command_names
|
|
33
|
-
|
|
41
|
+
builtin_slash_command_names + prompt_templates.map(&:command)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run_interactive_command(name, argument, agent)
|
|
45
|
+
command = interactive_command_for(name)
|
|
46
|
+
return [false, nil] unless command
|
|
47
|
+
return [false, nil] unless prompt_interface? && @prompt.respond_to?(:start_interactive)
|
|
48
|
+
|
|
49
|
+
context = plugin_context(agent.conversation, argument)
|
|
50
|
+
controller = @prompt.start_interactive(title: "/#{name}", rows: command.rows, fps: command.fps)
|
|
51
|
+
command.handler.call(controller, context)
|
|
52
|
+
run_interactive_loop
|
|
53
|
+
[true, nil]
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
@prompt.finish_interactive if @prompt.respond_to?(:finish_interactive)
|
|
56
|
+
runtime_output("Interactive command /#{name} error: #{e.message}")
|
|
57
|
+
[true, nil]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run_interactive_loop
|
|
61
|
+
loop do
|
|
62
|
+
result = @prompt.poll_input
|
|
63
|
+
if result == :interactive_exited || @prompt.interactive_exited?
|
|
64
|
+
@prompt.finish_interactive
|
|
65
|
+
break
|
|
66
|
+
end
|
|
67
|
+
sleep 0.01
|
|
68
|
+
end
|
|
34
69
|
end
|
|
35
70
|
|
|
36
71
|
def slash_command_entries
|
|
@@ -42,15 +77,30 @@ module Kward
|
|
|
42
77
|
}
|
|
43
78
|
end
|
|
44
79
|
plugin_entries = plugin_commands.map(&:entry)
|
|
45
|
-
|
|
80
|
+
interactive_entries = interactive_commands.map(&:entry)
|
|
81
|
+
builtin_slash_commands + prompt_entries + plugin_entries + interactive_entries
|
|
46
82
|
end
|
|
47
83
|
|
|
48
84
|
def prompt_template_for(command)
|
|
49
85
|
prompt_templates.find { |template| template.command == command }
|
|
50
86
|
end
|
|
51
87
|
|
|
88
|
+
def builtin_slash_commands
|
|
89
|
+
return BUILTIN_SLASH_COMMANDS if experimental_workers_enabled?
|
|
90
|
+
|
|
91
|
+
BUILTIN_SLASH_COMMANDS.reject { |command| command[:name] == "workers" }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def builtin_slash_command_names
|
|
95
|
+
builtin_slash_commands.map { |command| command[:name] }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def experimental_workers_enabled?
|
|
99
|
+
@experimental_workers == true
|
|
100
|
+
end
|
|
101
|
+
|
|
52
102
|
def expand_prompt_template(input)
|
|
53
|
-
PromptCommands.expand(input, templates: prompt_templates, reserved_commands:
|
|
103
|
+
PromptCommands.expand(input, templates: prompt_templates, reserved_commands: builtin_slash_command_names)
|
|
54
104
|
end
|
|
55
105
|
|
|
56
106
|
def run_plugin_command(name, argument, agent)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
1
3
|
# Namespace for the Kward CLI agent runtime.
|
|
2
4
|
module Kward
|
|
3
5
|
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
@@ -13,7 +15,6 @@ module Kward
|
|
|
13
15
|
prompt_interface = load_prompt_interface
|
|
14
16
|
return unless prompt_interface
|
|
15
17
|
|
|
16
|
-
banner_enabled = ConfigFiles.banner_enabled?
|
|
17
18
|
@prompt = prompt_interface.new(
|
|
18
19
|
slash_commands: slash_command_entries,
|
|
19
20
|
overlay_settings: ConfigFiles.overlay_settings,
|
|
@@ -22,8 +23,21 @@ module Kward
|
|
|
22
23
|
busy_help: ConfigFiles.composer_busy_help?,
|
|
23
24
|
attachment_badges: method(:composer_attachment_badges),
|
|
24
25
|
attachment_parser: method(:composer_attachment_parser),
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
banner_message: Kward::PromptInterface::BANNER_MESSAGE,
|
|
27
|
+
tab_keybindings: ConfigFiles.composer_tab_keybindings,
|
|
28
|
+
prompt_history: PromptHistory.new(cwd: current_workspace_root),
|
|
29
|
+
editor_mode: ConfigFiles.editor_mode,
|
|
30
|
+
editor_mode_source: -> { ConfigFiles.editor_mode },
|
|
31
|
+
editor_auto_indent: ConfigFiles.editor_auto_indent?,
|
|
32
|
+
editor_auto_indent_source: -> { ConfigFiles.editor_auto_indent? },
|
|
33
|
+
editor_auto_close_pairs: ConfigFiles.editor_auto_close_pairs?,
|
|
34
|
+
editor_auto_close_pairs_source: -> { ConfigFiles.editor_auto_close_pairs? },
|
|
35
|
+
editor_soft_wrap: ConfigFiles.editor_soft_wrap?,
|
|
36
|
+
editor_soft_wrap_source: -> { ConfigFiles.editor_soft_wrap? },
|
|
37
|
+
editor_bar_cursor: ConfigFiles.editor_bar_cursor?,
|
|
38
|
+
editor_bar_cursor_source: -> { ConfigFiles.editor_bar_cursor? },
|
|
39
|
+
editor_line_numbers: ConfigFiles.editor_line_numbers,
|
|
40
|
+
editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers }
|
|
27
41
|
)
|
|
28
42
|
if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
|
|
29
43
|
@prompt.start(render: false)
|
|
@@ -50,9 +64,80 @@ module Kward
|
|
|
50
64
|
@prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
|
|
51
65
|
end
|
|
52
66
|
|
|
53
|
-
# Writes the
|
|
67
|
+
# Writes the startup info screen output for the terminal CLI flow.
|
|
54
68
|
def print_visual_banner
|
|
55
|
-
|
|
69
|
+
return unless @prompt.respond_to?(:print_visual_banner)
|
|
70
|
+
|
|
71
|
+
@prompt.print_visual_banner(startup_info_screen)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def startup_info_screen
|
|
75
|
+
[
|
|
76
|
+
startup_status_line,
|
|
77
|
+
"",
|
|
78
|
+
startup_info_line("Workspace", startup_workspace_label),
|
|
79
|
+
startup_info_line("Branch", startup_branch_value),
|
|
80
|
+
startup_info_line("Plugins", startup_plugins_value),
|
|
81
|
+
"",
|
|
82
|
+
startup_brand_line
|
|
83
|
+
].join("\n")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def startup_workspace_label
|
|
87
|
+
root = File.expand_path(current_workspace_root)
|
|
88
|
+
home = begin
|
|
89
|
+
Dir.home
|
|
90
|
+
rescue StandardError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
if home && (root == home || root.start_with?("#{home}/"))
|
|
94
|
+
relative = root.delete_prefix(home).sub(%r{\A/}, "")
|
|
95
|
+
return "~" if relative.empty?
|
|
96
|
+
return "~/#{relative}" unless relative.include?("/")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
parent = File.basename(File.dirname(root))
|
|
100
|
+
name = File.basename(root)
|
|
101
|
+
parent.empty? || parent == "." ? name : "#{parent}/#{name}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def startup_branch_value
|
|
105
|
+
git_root = startup_git_root(current_workspace_root)
|
|
106
|
+
return "not a repository" if git_root.to_s.empty?
|
|
107
|
+
|
|
108
|
+
branch = startup_git_output(%w[git branch --show-current], root: git_root)
|
|
109
|
+
branch = startup_git_output(%w[git rev-parse --short HEAD], root: git_root) if branch.empty?
|
|
110
|
+
branch.empty? ? "unknown" : branch
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def startup_plugins_value
|
|
114
|
+
filenames = plugin_registry.paths.map { |path| File.basename(path) }
|
|
115
|
+
filenames.empty? ? "none" : filenames.join(", ")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def startup_status_line
|
|
119
|
+
"#{ANSI.colorize("●", :green, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def startup_info_line(label, value)
|
|
123
|
+
"#{ANSI.colorize(label.ljust(12), :gray, enabled: @color_enabled)}#{ANSI.colorize(value, :cyan, enabled: @color_enabled)}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def startup_brand_line
|
|
127
|
+
ANSI.colorize(Kward::PromptInterface::BANNER_MESSAGE, :bold, enabled: @color_enabled)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def startup_git_root(root)
|
|
131
|
+
startup_git_output(%w[git rev-parse --show-toplevel], root: root)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def startup_git_output(command, root:)
|
|
135
|
+
output, status = Open3.capture2e(*command, chdir: root.to_s)
|
|
136
|
+
return "" unless status.success?
|
|
137
|
+
|
|
138
|
+
output.lines.first.to_s.strip
|
|
139
|
+
rescue StandardError
|
|
140
|
+
""
|
|
56
141
|
end
|
|
57
142
|
|
|
58
143
|
def prompt_footer_renderer
|
|
@@ -78,6 +163,8 @@ module Kward
|
|
|
78
163
|
reasoning = "n/a" unless ModelInfo.reasoning_supported?(provider, model) && !reasoning.to_s.empty?
|
|
79
164
|
text = "#{provider} #{model} · #{reasoning}"
|
|
80
165
|
parts = []
|
|
166
|
+
git = composer_git_branch_text
|
|
167
|
+
parts << git if git
|
|
81
168
|
diff = composer_session_diff_text
|
|
82
169
|
parts << diff if diff
|
|
83
170
|
usage = composer_context_usage(provider, model)
|
|
@@ -94,6 +181,21 @@ module Kward
|
|
|
94
181
|
"#{additions}|#{deletions}"
|
|
95
182
|
end
|
|
96
183
|
|
|
184
|
+
def composer_git_branch_text
|
|
185
|
+
git_root = startup_git_root(current_workspace_root)
|
|
186
|
+
return nil if git_root.to_s.empty?
|
|
187
|
+
|
|
188
|
+
branch = startup_git_output(%w[git branch --show-current], root: git_root)
|
|
189
|
+
branch = startup_git_output(%w[git rev-parse --short HEAD], root: git_root) if branch.empty?
|
|
190
|
+
branch = "unknown" if branch.empty?
|
|
191
|
+
color = composer_git_dirty?(git_root) ? :yellow : nil
|
|
192
|
+
ANSI.colorize(branch, color, enabled: @color_enabled)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def composer_git_dirty?(git_root)
|
|
196
|
+
!startup_git_output(%w[git status --porcelain --untracked-files=normal], root: git_root).empty?
|
|
197
|
+
end
|
|
198
|
+
|
|
97
199
|
def composer_context_percent_text(percent)
|
|
98
200
|
value = percent.round
|
|
99
201
|
color = if value >= 85
|
|
@@ -107,7 +209,10 @@ module Kward
|
|
|
107
209
|
def composer_context_window(provider = nil, model = nil)
|
|
108
210
|
provider ||= current_footer_conversation.provider || (@client.respond_to?(:current_provider) ? @client.current_provider : "Codex")
|
|
109
211
|
model ||= current_footer_conversation.model || (@client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL)
|
|
110
|
-
ModelInfo.
|
|
212
|
+
provider = ModelInfo.provider_label(provider)
|
|
213
|
+
return @client.context_window(provider, model) if @client.respond_to?(:context_window) && @client.method(:context_window).arity != 0
|
|
214
|
+
|
|
215
|
+
ModelInfo.context_window(provider, model)
|
|
111
216
|
end
|
|
112
217
|
|
|
113
218
|
def composer_context_usage(provider, model)
|
data/lib/kward/cli/rendering.rb
CHANGED
|
@@ -59,7 +59,7 @@ module Kward
|
|
|
59
59
|
if prompt_interface?
|
|
60
60
|
print_tool_result(tool_call, content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
61
61
|
else
|
|
62
|
-
@prompt.say("\n#{colored("Tool>", *tool_label_styles(content))}
|
|
62
|
+
@prompt.say("\n#{colored("Tool>", *tool_label_styles(content))} #{tool_summary_display_text(summary)}\n")
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
@@ -71,7 +71,7 @@ module Kward
|
|
|
71
71
|
print_block_delta(label, rendered)
|
|
72
72
|
finish_stream_block
|
|
73
73
|
else
|
|
74
|
-
@prompt.say("\n#{colored("#{transcript_label(label)}>", *label_styles(label))}
|
|
74
|
+
@prompt.say("\n#{colored("#{transcript_label(label)}>", *label_styles(label))} #{rendered}\n")
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
|
|
@@ -298,8 +298,9 @@ module Kward
|
|
|
298
298
|
def print_tool_result(tool_call, content, line_limit: nil)
|
|
299
299
|
summary = tool_result_summary(tool_call, content)
|
|
300
300
|
summary = limit_tool_output_lines(summary, line_limit) if line_limit
|
|
301
|
+
display_summary = tool_summary_display_text(summary)
|
|
301
302
|
if prompt_interface?
|
|
302
|
-
summary =
|
|
303
|
+
summary = display_summary.end_with?("\n") ? display_summary : "#{display_summary}\n"
|
|
303
304
|
if @prompt.respond_to?(:write_stream_block)
|
|
304
305
|
@prompt.write_stream_block("Tool", summary, finish: true)
|
|
305
306
|
else
|
|
@@ -309,18 +310,22 @@ module Kward
|
|
|
309
310
|
end
|
|
310
311
|
else
|
|
311
312
|
start_stream_block(tool_stream_label(content))
|
|
312
|
-
print
|
|
313
|
-
puts unless
|
|
313
|
+
print display_summary
|
|
314
|
+
puts unless display_summary.end_with?("\n")
|
|
314
315
|
$stdout.flush
|
|
315
316
|
@stream_block = nil
|
|
316
317
|
end
|
|
317
318
|
end
|
|
318
319
|
|
|
320
|
+
def tool_summary_display_text(summary)
|
|
321
|
+
summary.to_s.sub("\n", "\n\n")
|
|
322
|
+
end
|
|
323
|
+
|
|
319
324
|
def start_stream_block(label)
|
|
320
325
|
return if @stream_block == label
|
|
321
326
|
|
|
322
327
|
puts if @stream_block
|
|
323
|
-
|
|
328
|
+
print "\n#{colored("#{transcript_label(label)}>", *label_styles(label))} "
|
|
324
329
|
@stream_block = label
|
|
325
330
|
end
|
|
326
331
|
|
|
@@ -43,9 +43,22 @@ module Kward
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def build_interactive_agent(conversation)
|
|
46
|
+
@active_worker_role = "implementation"
|
|
47
|
+
set_visible_worker("implementation", status: "active")
|
|
48
|
+
build_worker_agent(conversation, role: "implementation")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_worker_agent(conversation, role: "implementation")
|
|
46
52
|
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
47
53
|
workspace = configured_workspace(root: conversation.workspace_root)
|
|
48
|
-
|
|
54
|
+
writer_id = worker_writer_id(role)
|
|
55
|
+
tool_registry = ToolRegistry.new(
|
|
56
|
+
workspace: workspace,
|
|
57
|
+
prompt: @prompt,
|
|
58
|
+
allowed_tool_names: Workers::ToolPolicy.allowed_tool_names(role),
|
|
59
|
+
write_lock: @worker_write_lock,
|
|
60
|
+
writer_id: writer_id
|
|
61
|
+
)
|
|
49
62
|
@footer_conversation = conversation
|
|
50
63
|
@footer_tool_registry = tool_registry
|
|
51
64
|
Agent.new(
|
|
@@ -55,6 +68,34 @@ module Kward
|
|
|
55
68
|
)
|
|
56
69
|
end
|
|
57
70
|
|
|
71
|
+
def set_visible_worker(id, status: nil, worker: nil)
|
|
72
|
+
@visible_worker_id = id.to_s
|
|
73
|
+
@visible_worker_status = status
|
|
74
|
+
@visible_worker = worker
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def worker_writer_id(role)
|
|
78
|
+
return nil unless Workers::ToolPolicy.write_capable?(role)
|
|
79
|
+
|
|
80
|
+
@worker_write_lock ||= Workers::WriteLock.new
|
|
81
|
+
owner_id = role.to_s.empty? ? "implementation" : role.to_s
|
|
82
|
+
return owner_id if @worker_write_lock.acquire(owner_id)
|
|
83
|
+
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def refresh_implementation_writer(agent)
|
|
88
|
+
return agent unless @active_worker_role == "implementation"
|
|
89
|
+
return agent unless agent&.respond_to?(:tool_registry)
|
|
90
|
+
return agent if agent.tool_registry.writer_id && @worker_write_lock&.owned_by?(agent.tool_registry.writer_id)
|
|
91
|
+
|
|
92
|
+
build_interactive_agent(agent.conversation)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def release_implementation_writer
|
|
96
|
+
@worker_write_lock&.release("implementation")
|
|
97
|
+
end
|
|
98
|
+
|
|
58
99
|
def handle_interactive_shell_command(input, agent)
|
|
59
100
|
command = input.to_s.sub(/\A!\s*/, "")
|
|
60
101
|
if command.strip.empty?
|
|
@@ -73,6 +114,95 @@ module Kward
|
|
|
73
114
|
input.to_s.start_with?("!")
|
|
74
115
|
end
|
|
75
116
|
|
|
117
|
+
def run_ekwsh(agent)
|
|
118
|
+
unless @prompt.respond_to?(:ask)
|
|
119
|
+
runtime_output("The embedded shell is only available in interactive mode.")
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
tab = active_tab if respond_to?(:active_tab, true)
|
|
124
|
+
entering = tab.nil? || tab.shell.nil?
|
|
125
|
+
shell = tab&.shell || build_ekwsh(agent)
|
|
126
|
+
tab.shell = shell if tab
|
|
127
|
+
runtime_output("Entering ekwsh. Type exit or press Ctrl+D on an empty prompt to return.") if entering
|
|
128
|
+
run_ekwsh_loop(shell, tab: tab)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_ekwsh(agent)
|
|
132
|
+
config = ConfigFiles.read_ekwsh_config
|
|
133
|
+
Ekwsh.new(cwd: interactive_workspace_root(agent), configured_env: config[:env], aliases: config[:aliases])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def run_ekwsh_loop(shell, tab: nil)
|
|
137
|
+
loop do
|
|
138
|
+
if @prompt.respond_to?(:editing_file?) && @prompt.editing_file?
|
|
139
|
+
editor_result = @prompt.run_editor
|
|
140
|
+
if editor_result.is_a?(Hash) && editor_result[:tab_action]
|
|
141
|
+
(@pending_inputs ||= []).unshift(editor_result)
|
|
142
|
+
return :tab_action
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
input = ask_ekwsh(shell)
|
|
147
|
+
if input.is_a?(Hash) && input[:tab_action]
|
|
148
|
+
(@pending_inputs ||= []).unshift(input)
|
|
149
|
+
return :tab_action
|
|
150
|
+
end
|
|
151
|
+
break if input.nil?
|
|
152
|
+
|
|
153
|
+
result = run_ekwsh_command(shell, input)
|
|
154
|
+
@prompt.clear_transcript if result.clear && @prompt.respond_to?(:clear_transcript)
|
|
155
|
+
@prompt.say(result.output) unless result.output.to_s.empty?
|
|
156
|
+
if result.open_editor_path
|
|
157
|
+
editor_result = open_ekwsh_editor(result.open_editor_path, shell)
|
|
158
|
+
return :tab_action if editor_result == :tab_action
|
|
159
|
+
|
|
160
|
+
next
|
|
161
|
+
end
|
|
162
|
+
if result.exit_shell
|
|
163
|
+
tab.shell = nil if tab
|
|
164
|
+
runtime_output("Shell exited.")
|
|
165
|
+
return :exited
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
tab.shell = nil if tab
|
|
169
|
+
runtime_output("Shell exited.")
|
|
170
|
+
:exited
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def open_ekwsh_editor(path, shell)
|
|
174
|
+
unless @prompt.respond_to?(:edit_file)
|
|
175
|
+
runtime_output("Integrated editor is unavailable in this prompt.")
|
|
176
|
+
return false
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
result = @prompt.edit_file(path, base_dir: shell.cwd, allow_new: true)
|
|
180
|
+
if result.is_a?(Hash) && result[:tab_action]
|
|
181
|
+
(@pending_inputs ||= []).unshift(result)
|
|
182
|
+
return :tab_action
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
result
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def ask_ekwsh(shell)
|
|
189
|
+
provider = ->(input, cursor) { shell.complete(input, cursor) }
|
|
190
|
+
if @prompt.respond_to?(:with_completion_provider)
|
|
191
|
+
@prompt.with_completion_provider(provider) { @prompt.ask(shell.prompt_label) }
|
|
192
|
+
else
|
|
193
|
+
@prompt.ask(shell.prompt_label)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def run_ekwsh_command(shell, input)
|
|
198
|
+
if @prompt.respond_to?(:begin_busy_input)
|
|
199
|
+
@prompt.begin_busy_input(shell.prompt_label, activity: "running")
|
|
200
|
+
end
|
|
201
|
+
shell.run(input)
|
|
202
|
+
ensure
|
|
203
|
+
@prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
|
|
204
|
+
end
|
|
205
|
+
|
|
76
206
|
def configured_workspace(root: current_workspace_root)
|
|
77
207
|
Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
|
|
78
208
|
end
|
|
@@ -146,10 +276,10 @@ module Kward
|
|
|
146
276
|
@client.reload_config if @client.respond_to?(:reload_config)
|
|
147
277
|
end
|
|
148
278
|
|
|
149
|
-
def refresh_conversation_runtime(conversation)
|
|
279
|
+
def refresh_conversation_runtime(conversation, reasoning_effort: current_reasoning_effort, refresh_system_message: true)
|
|
150
280
|
return unless conversation&.respond_to?(:update_runtime_context!)
|
|
151
281
|
|
|
152
|
-
conversation.update_runtime_context!(provider: current_model_provider, model: current_model_id, reasoning_effort:
|
|
282
|
+
conversation.update_runtime_context!(provider: current_model_provider, model: current_model_id, reasoning_effort: reasoning_effort, refresh: refresh_system_message)
|
|
153
283
|
update_assistant_prompt(conversation)
|
|
154
284
|
end
|
|
155
285
|
|