kward 0.76.0 → 0.78.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/ci.yml +8 -1
- data/CHANGELOG.md +62 -0
- data/Gemfile.lock +2 -2
- data/README.md +13 -4
- data/doc/agent-tools.md +0 -3
- data/doc/api.md +4 -0
- data/doc/code-search.md +9 -6
- data/doc/composer.md +219 -0
- data/doc/configuration.md +114 -27
- data/doc/editor.md +18 -2
- data/doc/files.md +1 -1
- data/doc/getting-started.md +4 -0
- data/doc/git.md +2 -2
- data/doc/lifecycle-hooks.md +2 -14
- data/doc/pan.md +167 -0
- data/doc/permissions.md +179 -0
- data/doc/plugins.md +58 -0
- data/doc/rpc.md +61 -22
- data/doc/security.md +184 -0
- data/doc/tabs.md +4 -3
- data/doc/usage.md +15 -32
- data/doc/web-search.md +4 -2
- data/lib/kward/ansi.rb +2 -2
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cancellation.rb +2 -2
- data/lib/kward/cli/commands.rb +1 -3
- data/lib/kward/cli/interactive_turn.rb +0 -17
- data/lib/kward/cli/plugins.rb +24 -7
- data/lib/kward/cli/prompt_interface.rb +1 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +11 -35
- data/lib/kward/cli/sessions.rb +14 -5
- data/lib/kward/cli/settings.rb +22 -0
- data/lib/kward/cli/slash_commands.rb +1 -561
- data/lib/kward/cli/tabs.rb +154 -36
- data/lib/kward/cli.rb +30 -26
- data/lib/kward/compactor.rb +3 -3
- data/lib/kward/config_files.rb +28 -0
- data/lib/kward/ekwsh.rb +0 -4
- data/lib/kward/hooks/catalog.rb +0 -5
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +33 -11
- data/lib/kward/model/payloads.rb +6 -1
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/pan/index.html.erb +459 -64
- data/lib/kward/pan/kward_logo.png +0 -0
- data/lib/kward/pan/server.rb +248 -23
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +71 -1
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
- data/lib/kward/prompt_interface/layout.rb +0 -4
- data/lib/kward/prompt_interface/project_browser.rb +40 -1
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface/runtime_state.rb +4 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
- data/lib/kward/prompt_interface.rb +20 -1
- data/lib/kward/prompts/commands.rb +1 -3
- data/lib/kward/prompts.rb +20 -0
- data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
- data/lib/kward/rpc/server.rb +48 -41
- data/lib/kward/rpc/session_manager.rb +76 -25
- data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
- data/lib/kward/rpc/tool_metadata.rb +23 -0
- data/lib/kward/rpc/transcript_normalizer.rb +18 -5
- data/lib/kward/session_store.rb +55 -16
- data/lib/kward/skills/registry.rb +21 -1
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/base.rb +14 -0
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +45 -17
- data/lib/kward/tools/search/code.rb +47 -13
- data/lib/kward/tools/search/web.rb +61 -18
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/tool_call.rb +0 -4
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/transcript_export.rb +1 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +11 -12
- data/lib/kward/workers/git_guard.rb +0 -93
- data/lib/kward/workers/job.rb +0 -99
- data/lib/kward/workers/live_view.rb +0 -49
- data/lib/kward/workers/manager.rb +0 -328
- data/lib/kward/workers/queue_runner.rb +0 -166
- data/lib/kward/workers/queue_store.rb +0 -112
- data/lib/kward/workers/store.rb +0 -72
- data/lib/kward/workers/tool_policy.rb +0 -23
- data/lib/kward/workers/worker.rb +0 -82
- data/lib/kward/workers/write_lock.rb +0 -38
- data/lib/kward/workers.rb +0 -10
data/lib/kward/cli/tabs.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "json"
|
|
1
2
|
require "thread"
|
|
2
3
|
require_relative "../cancellation"
|
|
3
4
|
|
|
@@ -10,6 +11,7 @@ module Kward
|
|
|
10
11
|
TabRuntime = Struct.new(
|
|
11
12
|
:session,
|
|
12
13
|
:agent,
|
|
14
|
+
:driver,
|
|
13
15
|
:diff,
|
|
14
16
|
:snapshot,
|
|
15
17
|
:status,
|
|
@@ -58,6 +60,28 @@ module Kward
|
|
|
58
60
|
def ask_user_question(questions, cancellation: nil)
|
|
59
61
|
@cli.send(:ask_tab_user_question, @tab, questions, cancellation: cancellation)
|
|
60
62
|
end
|
|
63
|
+
|
|
64
|
+
def ask_tool_approval(tool_name:, args:, reason: nil)
|
|
65
|
+
answers = ask_user_question([
|
|
66
|
+
{
|
|
67
|
+
header: "Approval required · #{tool_name.to_s.tr("_", " ").capitalize}",
|
|
68
|
+
question: (["The agent wants to use #{tool_name}.", "Arguments:\n#{JSON.pretty_generate(args.to_h)}", reason].compact).join("\n"),
|
|
69
|
+
options: [
|
|
70
|
+
{ label: "Allow once", description: "Run this tool call." },
|
|
71
|
+
{ label: "Allow this tool for this session", description: "Run this call and future calls to #{tool_name}." },
|
|
72
|
+
{ label: "Deny", description: "Do not run this tool call." }
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
])
|
|
76
|
+
answer = answers&.first
|
|
77
|
+
return { denied_message: answer[:answer] } if answer&.fetch(:custom, false) && !answer[:answer].to_s.empty?
|
|
78
|
+
|
|
79
|
+
case answer&.fetch(:answer, nil)
|
|
80
|
+
when "Allow once" then true
|
|
81
|
+
when "Allow this tool for this session" then :allow_for_session
|
|
82
|
+
else false
|
|
83
|
+
end
|
|
84
|
+
end
|
|
61
85
|
end
|
|
62
86
|
|
|
63
87
|
private
|
|
@@ -72,8 +96,8 @@ module Kward
|
|
|
72
96
|
return restored if restored
|
|
73
97
|
|
|
74
98
|
if agent.nil? && (resumed_agent = resume_last_session(session_store))
|
|
75
|
-
|
|
76
|
-
@tabs << build_tab(@active_session,
|
|
99
|
+
restored_agent = build_tab_agent(resumed_agent.conversation, @active_session)
|
|
100
|
+
@tabs << build_tab(@active_session, restored_agent, driver: SessionTabDriver.new(session: @active_session, agent: restored_agent), label: default_tab_label(0))
|
|
77
101
|
return activate_tab(0, render: false)
|
|
78
102
|
end
|
|
79
103
|
|
|
@@ -87,19 +111,18 @@ module Kward
|
|
|
87
111
|
@active_session.attach(conversation)
|
|
88
112
|
tab_agent = build_tab_agent(conversation, @active_session)
|
|
89
113
|
end
|
|
90
|
-
@tabs << build_tab(@active_session, tab_agent, label: default_tab_label(0))
|
|
114
|
+
@tabs << build_tab(@active_session, tab_agent, driver: SessionTabDriver.new(session: @active_session, agent: tab_agent), label: default_tab_label(0))
|
|
91
115
|
activate_tab(0, render: false)
|
|
92
116
|
end
|
|
93
117
|
|
|
94
118
|
def restore_tabs(session_store)
|
|
95
119
|
data = @tab_store&.load || {}
|
|
96
|
-
|
|
97
|
-
return nil if
|
|
120
|
+
descriptors = Array(data["tabs"])
|
|
121
|
+
return nil if descriptors.empty?
|
|
98
122
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
@tabs << tab
|
|
123
|
+
descriptors.each_with_index do |_descriptor, index|
|
|
124
|
+
tab = build_tab_from_descriptor(data.fetch("tabs")[index], session_store, label: restored_tab_label(data, index))
|
|
125
|
+
@tabs << tab if tab
|
|
103
126
|
rescue StandardError
|
|
104
127
|
next
|
|
105
128
|
end
|
|
@@ -110,6 +133,47 @@ module Kward
|
|
|
110
133
|
activate_tab(@active_tab_index)
|
|
111
134
|
end
|
|
112
135
|
|
|
136
|
+
def build_tab_from_descriptor(descriptor, session_store, label: nil)
|
|
137
|
+
descriptor = descriptor.transform_keys(&:to_s)
|
|
138
|
+
if descriptor["kind"] == "session"
|
|
139
|
+
session, conversation = restore_tab_session(session_store, descriptor.fetch("session_path"))
|
|
140
|
+
agent = build_tab_agent(conversation, session)
|
|
141
|
+
return build_tab(session, agent, driver: SessionTabDriver.new(session: session, agent: agent), label: label)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
tab_type = plugin_registry.tab_type_for_id(descriptor["plugin_tab_type"])
|
|
145
|
+
driver = if tab_type
|
|
146
|
+
host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
|
|
147
|
+
tab_type.handler.call(host, descriptor)
|
|
148
|
+
else
|
|
149
|
+
UnavailableTabDriver.new(descriptor: descriptor, message: "Plugin tab #{descriptor["plugin_tab_type"].inspect} is unavailable.")
|
|
150
|
+
end
|
|
151
|
+
raise "Plugin tab #{descriptor["plugin_tab_type"].inspect} did not return a tab driver." unless driver
|
|
152
|
+
build_tab(nil, nil, driver: driver, label: label || descriptor["label"] || tab_type&.title)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def open_plugin_tab(name, session_store)
|
|
156
|
+
tab_type = plugin_registry.tab_type_for(name)
|
|
157
|
+
return runtime_output("Plugin tab #{name.inspect} is not available.") unless tab_type
|
|
158
|
+
|
|
159
|
+
if tab_type.singleton == :global && (existing = @tabs.find { |tab| tab.driver.descriptor["plugin_tab_type"] == tab_type.id })
|
|
160
|
+
return switch_tab(@tabs.index(existing))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
save_active_tab_state
|
|
164
|
+
stop_tab_live_view
|
|
165
|
+
descriptor = { "kind" => "plugin", "plugin_tab_type" => tab_type.id, "label" => tab_type.title }
|
|
166
|
+
host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
|
|
167
|
+
driver = tab_type.handler.call(host, descriptor)
|
|
168
|
+
raise "Plugin tab #{name.inspect} did not return a tab driver." unless driver
|
|
169
|
+
|
|
170
|
+
@tabs << build_tab(nil, nil, driver: driver, label: tab_type.title)
|
|
171
|
+
@active_tab_index = @tabs.length - 1
|
|
172
|
+
activate_tab(@active_tab_index)
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
runtime_output("Could not open plugin tab #{name.inspect}: #{e.message}")
|
|
175
|
+
end
|
|
176
|
+
|
|
113
177
|
def restore_tab_session(session_store, path)
|
|
114
178
|
if File.file?(path)
|
|
115
179
|
session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
@@ -131,6 +195,7 @@ module Kward
|
|
|
131
195
|
tool_registry = ToolRegistry.new(
|
|
132
196
|
workspace: workspace,
|
|
133
197
|
prompt: prompt,
|
|
198
|
+
tool_approval: tab_tool_approval_callback(prompt),
|
|
134
199
|
hook_manager: hook_manager,
|
|
135
200
|
hook_context: hook_context
|
|
136
201
|
)
|
|
@@ -147,10 +212,20 @@ module Kward
|
|
|
147
212
|
agent
|
|
148
213
|
end
|
|
149
214
|
|
|
150
|
-
def
|
|
215
|
+
def tab_tool_approval_callback(prompt)
|
|
216
|
+
return nil unless ConfigFiles.permission_policy(safely_read_config.to_h).enabled?
|
|
217
|
+
|
|
218
|
+
lambda do |tool_call:, name:, args:, cancellation:|
|
|
219
|
+
prompt.ask_tool_approval(tool_name: name, args: args, reason: args["hook_message"] || args[:hook_message])
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def build_tab(session, agent, driver: nil, label: nil)
|
|
224
|
+
driver ||= SessionTabDriver.new(session: session, agent: agent)
|
|
151
225
|
TabRuntime.new(
|
|
152
226
|
session: session,
|
|
153
227
|
agent: agent,
|
|
228
|
+
driver: driver,
|
|
154
229
|
diff: session&.path ? SessionDiff.from_session_file(session.path) : SessionDiff.new,
|
|
155
230
|
snapshot: nil,
|
|
156
231
|
status: "idle",
|
|
@@ -162,7 +237,7 @@ module Kward
|
|
|
162
237
|
steering: nil,
|
|
163
238
|
error: nil,
|
|
164
239
|
answer: nil,
|
|
165
|
-
stream_state: new_tab_stream_state(
|
|
240
|
+
stream_state: new_tab_stream_state(driver),
|
|
166
241
|
markdown_chunks: [],
|
|
167
242
|
label: label,
|
|
168
243
|
unread: false,
|
|
@@ -177,6 +252,17 @@ module Kward
|
|
|
177
252
|
@tabs && @tabs[@active_tab_index]
|
|
178
253
|
end
|
|
179
254
|
|
|
255
|
+
def plugin_tab?(tab = active_tab)
|
|
256
|
+
tab && tab.session.nil?
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def plugin_tab_command_allowed?(command)
|
|
260
|
+
name, = parse_slash_command(command)
|
|
261
|
+
return true if %w[tab exit quit].include?(name)
|
|
262
|
+
|
|
263
|
+
active_tab.driver.respond_to?(:handles_command?) && active_tab.driver.handles_command?(command)
|
|
264
|
+
end
|
|
265
|
+
|
|
180
266
|
def assign_tab_question_prompt(agent, tab)
|
|
181
267
|
prompt = agent.instance_variable_get(:@tab_question_prompt) if agent
|
|
182
268
|
prompt.tab = tab if prompt.respond_to?(:tab=)
|
|
@@ -243,7 +329,8 @@ module Kward
|
|
|
243
329
|
session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
|
|
244
330
|
conversation = new_conversation(workspace_root: session_store.cwd)
|
|
245
331
|
session.attach(conversation)
|
|
246
|
-
|
|
332
|
+
agent = build_tab_agent(conversation, session)
|
|
333
|
+
@tabs << build_tab(session, agent, driver: SessionTabDriver.new(session: session, agent: agent), label: default_tab_label(@tabs.length))
|
|
247
334
|
@active_tab_index = @tabs.length - 1
|
|
248
335
|
activate_tab(@active_tab_index)
|
|
249
336
|
end
|
|
@@ -263,6 +350,7 @@ module Kward
|
|
|
263
350
|
|
|
264
351
|
stop_tab_live_view
|
|
265
352
|
tab.session&.delete_if_unused if tab&.session.respond_to?(:delete_if_unused)
|
|
353
|
+
tab.driver.close if tab.driver.respond_to?(:close)
|
|
266
354
|
@tabs.delete_at(@active_tab_index)
|
|
267
355
|
@active_tab_index = [@active_tab_index, @tabs.length - 1].min
|
|
268
356
|
activate_tab(@active_tab_index)
|
|
@@ -285,6 +373,7 @@ module Kward
|
|
|
285
373
|
|
|
286
374
|
tab.session = @active_session
|
|
287
375
|
tab.agent = build_tab_agent(agent.conversation, tab.session)
|
|
376
|
+
tab.driver = SessionTabDriver.new(session: tab.session, agent: tab.agent)
|
|
288
377
|
assign_tab_question_prompt(tab.agent, tab)
|
|
289
378
|
tab.diff = @session_diff || (tab.session&.path ? SessionDiff.from_session_file(tab.session.path) : SessionDiff.new)
|
|
290
379
|
tab.snapshot = nil
|
|
@@ -298,7 +387,7 @@ module Kward
|
|
|
298
387
|
tab.queued_inputs.clear
|
|
299
388
|
tab.steering = nil
|
|
300
389
|
tab.shell = nil
|
|
301
|
-
tab.stream_state = new_tab_stream_state(tab.
|
|
390
|
+
tab.stream_state = new_tab_stream_state(tab.driver)
|
|
302
391
|
tab.markdown_chunks.clear
|
|
303
392
|
update_prompt_tabs
|
|
304
393
|
persist_tabs
|
|
@@ -323,11 +412,19 @@ module Kward
|
|
|
323
412
|
|
|
324
413
|
@active_session = tab.session
|
|
325
414
|
@session_diff = tab.diff || SessionDiff.new
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
415
|
+
if tab.agent
|
|
416
|
+
@footer_conversation = tab.agent.conversation
|
|
417
|
+
@footer_tool_registry = tab.agent.tool_registry if tab.agent.respond_to?(:tool_registry)
|
|
418
|
+
update_assistant_prompt(tab.agent.conversation)
|
|
419
|
+
else
|
|
420
|
+
@footer_conversation = nil
|
|
421
|
+
@footer_tool_registry = nil
|
|
422
|
+
@assistant_prompt = "#{tab.driver.assistant_label || "Plugin"}>"
|
|
423
|
+
@prompt.update_assistant_label(assistant_prompt_name) if @prompt.respond_to?(:update_assistant_label)
|
|
424
|
+
end
|
|
329
425
|
tab.unread = false
|
|
330
426
|
restore_tab_composer_snapshot(tab.snapshot)
|
|
427
|
+
update_plugin_tab_slash_commands(tab)
|
|
331
428
|
update_prompt_tabs
|
|
332
429
|
render_tab(tab) if render
|
|
333
430
|
start_tab_live_view(tab) if tab.running?
|
|
@@ -344,10 +441,10 @@ module Kward
|
|
|
344
441
|
end
|
|
345
442
|
|
|
346
443
|
restore_prompt_transcript do
|
|
347
|
-
if empty_tab_conversation?(tab.agent.conversation)
|
|
444
|
+
if tab.agent && empty_tab_conversation?(tab.agent.conversation)
|
|
348
445
|
print_visual_banner
|
|
349
446
|
else
|
|
350
|
-
|
|
447
|
+
render_transcript_messages(tab.driver.messages)
|
|
351
448
|
end
|
|
352
449
|
report_tab_runtime_error(tab) if %w[failed cancelled].include?(tab.status.to_s)
|
|
353
450
|
end
|
|
@@ -404,20 +501,19 @@ module Kward
|
|
|
404
501
|
end
|
|
405
502
|
|
|
406
503
|
def start_tab_turn(tab, input, display_input: nil)
|
|
407
|
-
stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
|
|
408
504
|
prepare_memory_context(tab.agent.conversation, input) if tab.agent.respond_to?(:conversation)
|
|
409
505
|
print_user_transcript(input, display_input: display_input) if prompt_interface?
|
|
410
506
|
tab.status = "queued"
|
|
411
507
|
tab.unread = false
|
|
412
508
|
tab.cancellation = Cancellation.new
|
|
413
|
-
tab.steering = steering_supported? ? Steering.new : nil
|
|
509
|
+
tab.steering = tab.driver.supports_steering? && steering_supported? ? Steering.new : nil
|
|
414
510
|
tab.error = nil
|
|
415
511
|
tab.answer = nil
|
|
416
512
|
tab.error_reported = false
|
|
417
513
|
tab.event_history.clear
|
|
418
514
|
tab.seen_events = 0
|
|
419
515
|
tab.markdown_chunks.clear
|
|
420
|
-
tab.stream_state = new_tab_stream_state(tab.
|
|
516
|
+
tab.stream_state = new_tab_stream_state(tab.driver)
|
|
421
517
|
update_prompt_tabs
|
|
422
518
|
tab.thread = Thread.new { run_tab_turn(tab, input, display_input: display_input) }
|
|
423
519
|
tab.thread.report_on_exception = false
|
|
@@ -425,12 +521,9 @@ module Kward
|
|
|
425
521
|
end
|
|
426
522
|
|
|
427
523
|
def run_tab_turn(tab, input, display_input: nil)
|
|
428
|
-
options = agent_display_options(display_input)
|
|
429
|
-
options[:cancellation] = tab.cancellation
|
|
430
|
-
options[:steering] = tab.steering if tab.steering
|
|
431
524
|
tab.status = "running"
|
|
432
525
|
update_prompt_tabs
|
|
433
|
-
tab.answer = tab.
|
|
526
|
+
tab.answer = tab.driver.submit(input, display_input: display_input, cancellation: tab.cancellation, steering: tab.steering) do |event|
|
|
434
527
|
tab.record_event(event)
|
|
435
528
|
end
|
|
436
529
|
tab.status = "ready"
|
|
@@ -489,7 +582,7 @@ module Kward
|
|
|
489
582
|
tab.queued_inputs << "/exit"
|
|
490
583
|
@prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
491
584
|
when String
|
|
492
|
-
handle_tab_busy_input(tab, poll_result)
|
|
585
|
+
return { tab_action: :busy_command } if handle_tab_busy_input(tab, poll_result) == :active_tab_changed
|
|
493
586
|
end
|
|
494
587
|
return next_tab_queued_input(tab) if tab.idle? && !tab.queued_inputs.empty?
|
|
495
588
|
return :tab_idle if tab.idle?
|
|
@@ -512,6 +605,15 @@ module Kward
|
|
|
512
605
|
tab.queued_inputs << input
|
|
513
606
|
@prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
514
607
|
return
|
|
608
|
+
elsif busy_tab_command?(input)
|
|
609
|
+
handle_tab_command(input.to_s.strip.sub(%r{\A/tab(?:\s+|\z)}, ""), @session_store)
|
|
610
|
+
unless active_tab.equal?(tab)
|
|
611
|
+
@prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
|
|
612
|
+
return :active_tab_changed
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
restore_busy_input_prompt
|
|
616
|
+
return
|
|
515
617
|
elsif slash_command_input?(input)
|
|
516
618
|
# Slash commands are local control actions. Running or queuing them
|
|
517
619
|
# from the busy composer is surprising because the state they act on
|
|
@@ -532,6 +634,10 @@ module Kward
|
|
|
532
634
|
@prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
533
635
|
end
|
|
534
636
|
|
|
637
|
+
def busy_tab_command?(input)
|
|
638
|
+
input.to_s.strip.match?(%r{\A/tab(?:\s|\z)})
|
|
639
|
+
end
|
|
640
|
+
|
|
535
641
|
def refresh_active_tab
|
|
536
642
|
tab = active_tab
|
|
537
643
|
return unless tab
|
|
@@ -562,10 +668,10 @@ module Kward
|
|
|
562
668
|
renderer = tab_live_renderer(tab)
|
|
563
669
|
until @tab_live_view_stop
|
|
564
670
|
events = tab.event_history[tab.seen_events..] || []
|
|
565
|
-
events.each { |event| renderer.call(event, tab.
|
|
671
|
+
events.each { |event| renderer.call(event, tab.driver) }
|
|
566
672
|
tab.seen_events += events.length
|
|
567
673
|
if tab.idle?
|
|
568
|
-
renderer.call(:flush, tab.
|
|
674
|
+
renderer.call(:flush, tab.driver)
|
|
569
675
|
break
|
|
570
676
|
end
|
|
571
677
|
sleep 0.05
|
|
@@ -575,7 +681,7 @@ module Kward
|
|
|
575
681
|
end
|
|
576
682
|
|
|
577
683
|
def tab_live_renderer(tab)
|
|
578
|
-
lambda do |event,
|
|
684
|
+
lambda do |event, driver|
|
|
579
685
|
if event == :flush
|
|
580
686
|
flush_interactive_markdown_deltas(tab.markdown_chunks, tab.stream_state, force: true)
|
|
581
687
|
render_tab_answer(tab)
|
|
@@ -583,7 +689,11 @@ module Kward
|
|
|
583
689
|
next
|
|
584
690
|
end
|
|
585
691
|
|
|
586
|
-
|
|
692
|
+
if driver.respond_to?(:conversation)
|
|
693
|
+
notify_plugin_transcript_event(event, driver.conversation)
|
|
694
|
+
elsif plugin_tab_type_for(driver)&.transcript_events
|
|
695
|
+
notify_plugin_tab_transcript_event(event, driver)
|
|
696
|
+
end
|
|
587
697
|
handle_interactive_event(event, tab.markdown_chunks, tab.stream_state)
|
|
588
698
|
flush_interactive_markdown_deltas(tab.markdown_chunks, tab.stream_state)
|
|
589
699
|
rescue StandardError => e
|
|
@@ -591,6 +701,10 @@ module Kward
|
|
|
591
701
|
end
|
|
592
702
|
end
|
|
593
703
|
|
|
704
|
+
def plugin_tab_type_for(driver)
|
|
705
|
+
plugin_registry.tab_type_for_id(driver.descriptor["plugin_tab_type"])
|
|
706
|
+
end
|
|
707
|
+
|
|
594
708
|
def render_tab_answer(tab)
|
|
595
709
|
return unless tab.status == "ready"
|
|
596
710
|
return if tab.stream_state[:streamed]
|
|
@@ -599,13 +713,13 @@ module Kward
|
|
|
599
713
|
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(tab.answer)}\n")
|
|
600
714
|
end
|
|
601
715
|
|
|
602
|
-
def new_tab_stream_state(
|
|
716
|
+
def new_tab_stream_state(driver)
|
|
603
717
|
{
|
|
604
718
|
streamed: false,
|
|
605
719
|
last_flush: monotonic_now,
|
|
606
720
|
stream_block_open: false,
|
|
607
721
|
markdown_streams: {},
|
|
608
|
-
defer_assistant_streaming: defer_assistant_streaming?(agent)
|
|
722
|
+
defer_assistant_streaming: driver.respond_to?(:conversation) && defer_assistant_streaming?(driver.agent)
|
|
609
723
|
}
|
|
610
724
|
end
|
|
611
725
|
|
|
@@ -638,7 +752,7 @@ module Kward
|
|
|
638
752
|
action, value = argument.to_s.strip.split(/\s+/, 2)
|
|
639
753
|
case action
|
|
640
754
|
when nil, ""
|
|
641
|
-
runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab name <label>")
|
|
755
|
+
runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label>")
|
|
642
756
|
nil
|
|
643
757
|
when /^\d+$/
|
|
644
758
|
switch_tab_number(action)
|
|
@@ -652,11 +766,16 @@ module Kward
|
|
|
652
766
|
when "new"
|
|
653
767
|
open_new_tab(session_store)
|
|
654
768
|
active_tab&.agent
|
|
769
|
+
when "open"
|
|
770
|
+
return runtime_output("Usage: /tab open <plugin-tab>") if value.to_s.strip.empty?
|
|
771
|
+
|
|
772
|
+
open_plugin_tab(value.strip, session_store)
|
|
773
|
+
active_tab&.agent
|
|
655
774
|
when "name", "rename"
|
|
656
775
|
rename_active_tab(value)
|
|
657
776
|
active_tab&.agent
|
|
658
777
|
else
|
|
659
|
-
runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab name <label>")
|
|
778
|
+
runtime_output("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label>")
|
|
660
779
|
nil
|
|
661
780
|
end
|
|
662
781
|
end
|
|
@@ -731,8 +850,7 @@ module Kward
|
|
|
731
850
|
return unless @tab_store
|
|
732
851
|
|
|
733
852
|
@tab_store.save(
|
|
734
|
-
|
|
735
|
-
labels: @tabs.map { |tab| tab.label.to_s },
|
|
853
|
+
tabs: @tabs.map { |tab| tab.driver.descriptor.merge("label" => tab.label.to_s) },
|
|
736
854
|
active_index: @active_tab_index
|
|
737
855
|
)
|
|
738
856
|
end
|
data/lib/kward/cli.rb
CHANGED
|
@@ -35,12 +35,12 @@ require_relative "session_diff"
|
|
|
35
35
|
require_relative "session_store"
|
|
36
36
|
require_relative "session_naming"
|
|
37
37
|
require_relative "tab_store"
|
|
38
|
+
require_relative "tab_driver"
|
|
38
39
|
require_relative "session_trash"
|
|
39
40
|
require_relative "session_tree_renderer"
|
|
40
41
|
require_relative "starter_pack_installer"
|
|
41
42
|
require_relative "steering"
|
|
42
43
|
require_relative "update_check"
|
|
43
|
-
require_relative "workers"
|
|
44
44
|
require_relative "tools/tool_call"
|
|
45
45
|
require_relative "tools/registry"
|
|
46
46
|
require_relative "telemetry/stats"
|
|
@@ -114,8 +114,6 @@ module Kward
|
|
|
114
114
|
@prompt_delimited = false
|
|
115
115
|
@requested_mode = "auto"
|
|
116
116
|
@skip_config = false
|
|
117
|
-
@experimental_workers = false
|
|
118
|
-
@foreground_turn_active = false
|
|
119
117
|
@pending_reasoning_config = nil
|
|
120
118
|
@pending_reasoning_config_mutex = Mutex.new
|
|
121
119
|
@color_enabled = ANSI.enabled?($stdout)
|
|
@@ -253,7 +251,7 @@ module Kward
|
|
|
253
251
|
raise ArgumentError, command_usage("rpc") unless @argv.length == 1
|
|
254
252
|
|
|
255
253
|
ensure_client!
|
|
256
|
-
Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client
|
|
254
|
+
Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client).run
|
|
257
255
|
return
|
|
258
256
|
end
|
|
259
257
|
|
|
@@ -441,13 +439,15 @@ module Kward
|
|
|
441
439
|
if input.is_a?(Hash) && input[:tab_action]
|
|
442
440
|
tab_result = handle_tab_action(input, session_store)
|
|
443
441
|
break if tab_result == PromptInterface::EXIT_INPUT
|
|
444
|
-
agent = active_tab.agent if active_tab
|
|
442
|
+
agent = active_tab.agent if active_tab&.agent
|
|
445
443
|
next
|
|
446
444
|
end
|
|
447
445
|
if input.is_a?(Hash) && input[:reasoning_action]
|
|
446
|
+
next if active_tab && plugin_tab?
|
|
447
|
+
|
|
448
448
|
conversation = active_tab ? active_tab.agent.conversation : agent.conversation
|
|
449
449
|
cycle_reasoning(conversation, direction: input[:reasoning_action], persist: :debounced)
|
|
450
|
-
agent = active_tab.agent if active_tab
|
|
450
|
+
agent = active_tab.agent if active_tab&.agent
|
|
451
451
|
next
|
|
452
452
|
end
|
|
453
453
|
next if input == :tab_idle
|
|
@@ -467,31 +467,37 @@ module Kward
|
|
|
467
467
|
display_input = input if display_input
|
|
468
468
|
end
|
|
469
469
|
break if ["/exit", "/quit"].include?(command)
|
|
470
|
+
if active_tab && plugin_tab? && command.start_with?("/")
|
|
471
|
+
unless plugin_tab_command_allowed?(command)
|
|
472
|
+
runtime_output("#{command.split.first} is unavailable in this plugin tab.")
|
|
473
|
+
next
|
|
474
|
+
end
|
|
475
|
+
if active_tab.driver.respond_to?(:handle_command) && active_tab.driver.handles_command?(command)
|
|
476
|
+
output = active_tab.driver.handle_command(command)
|
|
477
|
+
runtime_output(output) unless output.to_s.empty?
|
|
478
|
+
next
|
|
479
|
+
end
|
|
480
|
+
end
|
|
470
481
|
handled, replacement_agent = handle_local_slash_command(command, agent, session_store)
|
|
471
482
|
if replacement_agent?(replacement_agent)
|
|
472
483
|
agent = active_tab ? replace_active_tab_agent(replacement_agent) : replacement_agent
|
|
484
|
+
elsif active_tab&.agent
|
|
485
|
+
agent = active_tab.agent
|
|
473
486
|
end
|
|
474
487
|
end
|
|
475
488
|
next if handled
|
|
476
|
-
request_handled, request_replacement = handle_request_worker_input(command_input, agent, session_store)
|
|
477
|
-
if request_handled
|
|
478
|
-
if replacement_agent?(request_replacement)
|
|
479
|
-
agent = active_tab ? replace_active_tab_agent(request_replacement) : request_replacement
|
|
480
|
-
end
|
|
481
|
-
next
|
|
482
|
-
end
|
|
483
489
|
next if shell_command_input?(command_input) && handle_interactive_shell_command(command_input, agent)
|
|
484
490
|
|
|
485
|
-
flush_pending_reasoning_config(conversation: agent.conversation)
|
|
491
|
+
flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
|
|
492
|
+
flush_pending_reasoning_config(conversation: agent.conversation) unless active_tab
|
|
486
493
|
expanded_input = expand_prompt_template(input)
|
|
487
494
|
display_input = display_input || input if expanded_input
|
|
488
495
|
input = expanded_input || input
|
|
489
|
-
|
|
490
|
-
@footer_conversation = agent.conversation
|
|
496
|
+
@footer_conversation = active_tab.agent.conversation if active_tab&.agent
|
|
497
|
+
@footer_conversation = agent.conversation unless active_tab
|
|
491
498
|
begin
|
|
492
499
|
@rewind_return_leaf_id = nil
|
|
493
|
-
auto_name_active_session(display_input || input)
|
|
494
|
-
@foreground_turn_active = true if @active_worker_role == "implementation"
|
|
500
|
+
auto_name_active_session(display_input || input) unless active_tab && plugin_tab?
|
|
495
501
|
if active_tab
|
|
496
502
|
submit_tab_input(active_tab, input, display_input: display_input)
|
|
497
503
|
pending_inputs = []
|
|
@@ -503,22 +509,20 @@ module Kward
|
|
|
503
509
|
pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
|
|
504
510
|
rescue StandardError => e
|
|
505
511
|
runtime_output("Error: #{e.message}")
|
|
506
|
-
ensure
|
|
507
|
-
@foreground_turn_active = false if @active_worker_role == "implementation"
|
|
508
|
-
release_implementation_writer if @active_worker_role == "implementation"
|
|
509
512
|
end
|
|
510
513
|
end
|
|
511
514
|
|
|
512
|
-
flush_pending_reasoning_config(conversation: agent.conversation)
|
|
513
|
-
agent.conversation
|
|
515
|
+
flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
|
|
516
|
+
flush_pending_reasoning_config(conversation: agent.conversation) unless active_tab
|
|
517
|
+
active_tab&.agent&.conversation || agent.conversation
|
|
514
518
|
rescue Interrupt
|
|
515
|
-
flush_pending_reasoning_config(conversation: agent
|
|
519
|
+
flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
|
|
520
|
+
flush_pending_reasoning_config(conversation: agent&.conversation) unless active_tab
|
|
516
521
|
runtime_output("Goodbye.")
|
|
517
|
-
agent&.conversation
|
|
522
|
+
active_tab&.agent&.conversation || agent&.conversation
|
|
518
523
|
ensure
|
|
519
524
|
begin
|
|
520
525
|
stop_tabs if respond_to?(:stop_tabs, true)
|
|
521
|
-
stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
|
|
522
526
|
@prompt.close if prompt_interface?
|
|
523
527
|
ensure
|
|
524
528
|
cleanup_unused_sessions
|
data/lib/kward/compactor.rb
CHANGED
|
@@ -37,7 +37,7 @@ module Kward
|
|
|
37
37
|
|
|
38
38
|
Cut = Struct.new(:first_kept_index, :messages_to_summarize, :turn_prefix_messages, :split_turn, :preserved_messages, :preserved_start_index, keyword_init: true)
|
|
39
39
|
|
|
40
|
-
#
|
|
40
|
+
# Configuration for manual and automatic conversation compaction.
|
|
41
41
|
class Settings
|
|
42
42
|
DEFAULT_ENABLED = true
|
|
43
43
|
DEFAULT_RESERVE_TOKENS = 16_384
|
|
@@ -731,7 +731,7 @@ module Kward
|
|
|
731
731
|
SummarizationFailed = Compaction::SummarizationFailed
|
|
732
732
|
|
|
733
733
|
AUTO_COMPACTION_GUARD_RATIO = 0.10
|
|
734
|
-
|
|
734
|
+
AUTO_COMPACTION_EXTRA_GUARD_FLOOR = 12_000
|
|
735
735
|
|
|
736
736
|
# Creates an object for conversation compaction.
|
|
737
737
|
def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil)
|
|
@@ -805,7 +805,7 @@ module Kward
|
|
|
805
805
|
def self.auto_compaction_reserve_tokens(context_window:, configured_reserve_tokens:)
|
|
806
806
|
context_window_i = context_window.to_i
|
|
807
807
|
dynamic_guard = (context_window_i * AUTO_COMPACTION_GUARD_RATIO).to_i
|
|
808
|
-
[configured_reserve_tokens.to_i, dynamic_guard,
|
|
808
|
+
[configured_reserve_tokens.to_i, dynamic_guard, AUTO_COMPACTION_EXTRA_GUARD_FLOOR].max
|
|
809
809
|
end
|
|
810
810
|
|
|
811
811
|
def compaction_messages(custom_instructions = nil)
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "yaml"
|
|
|
5
5
|
require_relative "frontmatter"
|
|
6
6
|
require_relative "private_file"
|
|
7
7
|
require_relative "path_guard"
|
|
8
|
+
require_relative "permissions/policy"
|
|
8
9
|
require_relative "ekwsh"
|
|
9
10
|
require_relative "editor_mode"
|
|
10
11
|
require_relative "diff_view_mode"
|
|
@@ -24,7 +25,9 @@ module Kward
|
|
|
24
25
|
# Keep path decisions here. Higher-level code should ask `ConfigFiles` for
|
|
25
26
|
# config, prompt, skill, plugin, cache, memory, and session locations instead of
|
|
26
27
|
# reconstructing `~/.kward` paths independently.
|
|
28
|
+
# @api public
|
|
27
29
|
module ConfigFiles
|
|
30
|
+
# Raised when a user configuration file cannot be parsed.
|
|
28
31
|
class ConfigError < StandardError
|
|
29
32
|
attr_reader :path, :format, :detail
|
|
30
33
|
|
|
@@ -39,6 +42,7 @@ module Kward
|
|
|
39
42
|
MAX_SKILL_FILE_BYTES = 100_000
|
|
40
43
|
MAX_PROMPT_FILE_BYTES = 32 * 1024
|
|
41
44
|
DEFAULT_OVERLAY_SETTINGS = { "alignment" => "center", "width" => "maximum" }.freeze
|
|
45
|
+
PROJECT_BROWSER_ICON_THEMES = %w[off nerd-font].freeze
|
|
42
46
|
DEFAULT_PERSONAS = {
|
|
43
47
|
"characters" => [
|
|
44
48
|
{
|
|
@@ -87,10 +91,12 @@ module Kward
|
|
|
87
91
|
File.expand_path(ENV["KWARD_CONFIG_PATH"] || File.join(config_dir, "config.json"))
|
|
88
92
|
end
|
|
89
93
|
|
|
94
|
+
# @return [String] directory containing reusable local caches
|
|
90
95
|
def cache_dir
|
|
91
96
|
File.join(config_dir, "cache")
|
|
92
97
|
end
|
|
93
98
|
|
|
99
|
+
# @return [String] embedded-shell YAML config path
|
|
94
100
|
def ekwsh_config_path
|
|
95
101
|
File.join(config_dir, "ekwsh.yml")
|
|
96
102
|
end
|
|
@@ -103,6 +109,9 @@ module Kward
|
|
|
103
109
|
File.join(config_dir, "trusted_workspace_hooks.json")
|
|
104
110
|
end
|
|
105
111
|
|
|
112
|
+
# Returns a fresh default config suitable for first-run persistence.
|
|
113
|
+
#
|
|
114
|
+
# @return [Hash] deep-mutable config defaults
|
|
106
115
|
def default_config
|
|
107
116
|
{
|
|
108
117
|
"personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
|
|
@@ -124,6 +133,9 @@ module Kward
|
|
|
124
133
|
"diff_view" => "auto"
|
|
125
134
|
},
|
|
126
135
|
"overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
|
|
136
|
+
"project_browser" => {
|
|
137
|
+
"icons" => "off"
|
|
138
|
+
},
|
|
127
139
|
"web_search" => {
|
|
128
140
|
"enabled" => true,
|
|
129
141
|
"provider" => "auto",
|
|
@@ -142,6 +154,10 @@ module Kward
|
|
|
142
154
|
"mcpServers" => {},
|
|
143
155
|
"tools" => {
|
|
144
156
|
"workspace_guardrails" => true
|
|
157
|
+
},
|
|
158
|
+
"permissions" => {
|
|
159
|
+
"enabled" => false,
|
|
160
|
+
"mode" => "ask"
|
|
145
161
|
}
|
|
146
162
|
}
|
|
147
163
|
end
|
|
@@ -409,6 +425,13 @@ module Kward
|
|
|
409
425
|
settings
|
|
410
426
|
end
|
|
411
427
|
|
|
428
|
+
# Returns the project browser icon theme, or off when unset or invalid.
|
|
429
|
+
def project_browser_icon_theme(config = read_config)
|
|
430
|
+
browser = config["project_browser"].is_a?(Hash) ? config["project_browser"] : {}
|
|
431
|
+
theme = browser["icons"].to_s
|
|
432
|
+
PROJECT_BROWSER_ICON_THEMES.include?(theme) ? theme : "off"
|
|
433
|
+
end
|
|
434
|
+
|
|
412
435
|
# Returns whether the composer should show busy-state keyboard help.
|
|
413
436
|
def composer_busy_help?(config = read_config)
|
|
414
437
|
composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
|
|
@@ -470,6 +493,11 @@ module Kward
|
|
|
470
493
|
tools["workspace_guardrails"] != false
|
|
471
494
|
end
|
|
472
495
|
|
|
496
|
+
# Builds the opt-in model-tool permission policy from persisted configuration.
|
|
497
|
+
def permission_policy(config = read_config)
|
|
498
|
+
Permissions::Policy.from_config(config)
|
|
499
|
+
end
|
|
500
|
+
|
|
473
501
|
# Returns whether project-level Agent Skills should be loaded from the workspace.
|
|
474
502
|
def project_skills_trusted?(config = read_config)
|
|
475
503
|
skills = config["skills"].is_a?(Hash) ? config["skills"] : {}
|