kward 0.80.1 → 0.82.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 +62 -0
- data/Gemfile.lock +2 -2
- data/README.md +2 -2
- data/Rakefile +44 -4
- data/doc/composer.md +6 -9
- data/doc/configuration.md +13 -3
- data/doc/files.md +8 -2
- data/doc/permissions.md +1 -1
- data/doc/releasing.md +71 -38
- data/doc/rpc.md +2 -1
- data/doc/sandboxing.md +4 -4
- data/doc/security.md +6 -9
- data/doc/shell.md +161 -196
- data/doc/skills.md +17 -5
- data/doc/tabs.md +1 -1
- data/doc/usage.md +6 -5
- data/kward.gemspec +1 -1
- data/lib/kward/adaptive_pty_output_sink.rb +183 -0
- data/lib/kward/ansi.rb +9 -1
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +5 -1
- data/lib/kward/cli/interactive_turn.rb +1 -1
- data/lib/kward/cli/plugins.rb +1 -1
- data/lib/kward/cli/project_skills.rb +99 -0
- data/lib/kward/cli/project_skills_commands.rb +87 -0
- data/lib/kward/cli/prompt_interface.rb +18 -4
- data/lib/kward/cli/rendering.rb +41 -3
- data/lib/kward/cli/runtime_helpers.rb +222 -21
- data/lib/kward/cli/sessions.rb +6 -4
- data/lib/kward/cli/settings.rb +5 -13
- data/lib/kward/cli/slash_commands.rb +6 -0
- data/lib/kward/cli/tabs.rb +62 -5
- data/lib/kward/cli.rb +21 -1
- data/lib/kward/config_files.rb +9 -4
- data/lib/kward/conversation.rb +8 -5
- data/lib/kward/ekwsh.rb +37 -16
- data/lib/kward/image_attachments.rb +98 -15
- data/lib/kward/interactive_pty_runner.rb +102 -30
- data/lib/kward/prompt_interface/composer_controller.rb +15 -3
- data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/controller.rb +10 -6
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +1 -1
- data/lib/kward/prompt_interface/key_handler.rb +100 -43
- data/lib/kward/prompt_interface/overlay_renderer.rb +13 -0
- data/lib/kward/prompt_interface/project_browser.rb +162 -3
- data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/screen.rb +40 -15
- data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
- data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
- data/lib/kward/prompt_interface.rb +167 -44
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +6 -6
- data/lib/kward/pty_output_sink.rb +45 -0
- data/lib/kward/pty_transcript_normalizer.rb +93 -0
- data/lib/kward/rpc/server.rb +1 -0
- data/lib/kward/session_catalog.rb +87 -0
- data/lib/kward/session_store.rb +97 -7
- data/lib/kward/skills/registry.rb +52 -2
- data/lib/kward/skills/trust_coordinator.rb +45 -0
- data/lib/kward/skills/trust_store.rb +107 -0
- data/lib/kward/terminal_image_support.rb +116 -0
- data/lib/kward/terminal_sequences.rb +43 -0
- data/lib/kward/version.rb +1 -1
- metadata +11 -4
- data/.github/workflows/ci.yml +0 -48
- data/.github/workflows/pages.yml +0 -48
|
@@ -4,10 +4,20 @@ module Kward
|
|
|
4
4
|
class CLI
|
|
5
5
|
# Shared runtime construction helpers for CLI conversations, workspaces, plugins, and sessions.
|
|
6
6
|
module RuntimeHelpers
|
|
7
|
+
MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES = 1_048_576
|
|
8
|
+
UNSAFE_TRANSCRIPT_CONTROL_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.freeze
|
|
9
|
+
|
|
7
10
|
private
|
|
8
11
|
|
|
9
12
|
def new_conversation(workspace_root: current_workspace_root)
|
|
10
|
-
Conversation.new(
|
|
13
|
+
Conversation.new(
|
|
14
|
+
workspace_root: workspace_root,
|
|
15
|
+
provider: current_model_provider,
|
|
16
|
+
model: current_model_id,
|
|
17
|
+
reasoning_effort: current_reasoning_effort,
|
|
18
|
+
plugin_registry: plugin_registry,
|
|
19
|
+
project_skill_paths: project_skill_paths_for(workspace_root)
|
|
20
|
+
)
|
|
11
21
|
end
|
|
12
22
|
|
|
13
23
|
def update_assistant_prompt(conversation)
|
|
@@ -50,6 +60,7 @@ module Kward
|
|
|
50
60
|
tool_registry = ToolRegistry.new(
|
|
51
61
|
workspace: workspace,
|
|
52
62
|
prompt: @prompt,
|
|
63
|
+
skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths_for(conversation.workspace_root)),
|
|
53
64
|
tool_approval: interactive_tool_approval_callback,
|
|
54
65
|
hook_manager: hook_manager,
|
|
55
66
|
hook_context: hook_context
|
|
@@ -86,17 +97,91 @@ module Kward
|
|
|
86
97
|
return true
|
|
87
98
|
end
|
|
88
99
|
|
|
100
|
+
shell = bang_shell(agent)
|
|
101
|
+
editor_result = shell.editor_command_result(command)
|
|
102
|
+
if editor_result
|
|
103
|
+
record_tab_transient_shell_output(editor_result.output, render: false)
|
|
104
|
+
@prompt.say(editor_result.output) unless editor_result.output.to_s.empty?
|
|
105
|
+
open_ekwsh_editor(editor_result.open_editor_path, shell) if editor_result.open_editor_path
|
|
106
|
+
return true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
expanded_command = shell.expand_alias(command, interactive: true)
|
|
110
|
+
run_user_interactive_pty_command(
|
|
111
|
+
expanded_command,
|
|
112
|
+
shell: Ekwsh::DEFAULT_SHELL,
|
|
113
|
+
env: interactive_pty_environment({}, preserve_git_pager: true),
|
|
114
|
+
cwd: interactive_workspace_root(agent),
|
|
115
|
+
intro: "$ #{command}\n"
|
|
116
|
+
)
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_captured_shell_command(command, agent)
|
|
121
|
+
command = command.to_s.strip
|
|
122
|
+
if command.empty?
|
|
123
|
+
runtime_output("Usage: /capture <command>")
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
89
127
|
run_busy_local_command_and_requeue(activity: "running") do
|
|
90
|
-
result =
|
|
128
|
+
result = Workspace.new(root: interactive_workspace_root(agent)).run_shell_command(command)
|
|
91
129
|
@prompt.say("\n#{colored("Shell>", :cyan, :bold)} #{command}\n#{result}\n")
|
|
92
130
|
end
|
|
93
|
-
true
|
|
94
131
|
end
|
|
95
132
|
|
|
96
133
|
def shell_command_input?(input)
|
|
97
134
|
input.to_s.start_with?("!")
|
|
98
135
|
end
|
|
99
136
|
|
|
137
|
+
def complete_bang_command(input, cursor, agent)
|
|
138
|
+
value = input.to_s
|
|
139
|
+
return false unless value.start_with?("!")
|
|
140
|
+
return nil if cursor.to_i <= 1
|
|
141
|
+
|
|
142
|
+
completion = bang_shell(agent).complete(value[1..], cursor.to_i - 1)
|
|
143
|
+
return nil unless completion
|
|
144
|
+
|
|
145
|
+
Ekwsh::Completion.new(
|
|
146
|
+
range: (completion.range.begin + 1)...(completion.range.end + 1),
|
|
147
|
+
replacement: completion.replacement,
|
|
148
|
+
candidates: completion.candidates
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def bang_shell(agent)
|
|
153
|
+
root = File.expand_path(interactive_workspace_root(agent).to_s)
|
|
154
|
+
aliases = ConfigFiles.read_ekwsh_config[:aliases]
|
|
155
|
+
cache_key = [root, ENV.fetch("PATH", ""), aliases.sort]
|
|
156
|
+
return @bang_shell if @bang_shell_key == cache_key
|
|
157
|
+
|
|
158
|
+
@bang_shell_key = cache_key
|
|
159
|
+
@bang_shell = Ekwsh.new(cwd: root, env: ENV.to_h, aliases: aliases)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def install_bang_completion_provider(agent)
|
|
163
|
+
return unless @prompt.respond_to?(:update_completion_provider)
|
|
164
|
+
|
|
165
|
+
@bang_completion_provider_installed = true
|
|
166
|
+
provider = lambda do |input, cursor|
|
|
167
|
+
current_agent = if respond_to?(:active_tab, true) && active_tab&.agent
|
|
168
|
+
active_tab.agent
|
|
169
|
+
else
|
|
170
|
+
agent
|
|
171
|
+
end
|
|
172
|
+
complete_bang_command(input, cursor, current_agent)
|
|
173
|
+
end
|
|
174
|
+
@prompt.update_completion_provider(provider)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def clear_bang_completion_provider
|
|
178
|
+
return unless @bang_completion_provider_installed
|
|
179
|
+
return unless @prompt.respond_to?(:update_completion_provider)
|
|
180
|
+
|
|
181
|
+
@prompt.update_completion_provider(nil)
|
|
182
|
+
@bang_completion_provider_installed = false
|
|
183
|
+
end
|
|
184
|
+
|
|
100
185
|
def run_ekwsh(agent)
|
|
101
186
|
unless @prompt.respond_to?(:ask)
|
|
102
187
|
runtime_output("The embedded shell is only available in interactive mode.")
|
|
@@ -140,29 +225,118 @@ module Kward
|
|
|
140
225
|
end
|
|
141
226
|
|
|
142
227
|
config = ConfigFiles.read_ekwsh_config
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
228
|
+
run_user_interactive_pty_command(
|
|
229
|
+
command,
|
|
230
|
+
shell: config[:shell],
|
|
231
|
+
env: interactive_pty_environment(config[:env]),
|
|
232
|
+
cwd: interactive_workspace_root(agent)
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def run_user_interactive_pty_command(command, shell:, env:, cwd:, intro: nil)
|
|
237
|
+
intro_message = intro || "$ #{command}\n"
|
|
238
|
+
record_tab_transient_shell_output(intro_message, render: false)
|
|
239
|
+
if @prompt.respond_to?(:write_transcript)
|
|
240
|
+
@prompt.write_transcript(intro_message)
|
|
241
|
+
elsif @prompt.respond_to?(:say)
|
|
242
|
+
@prompt.say(intro_message)
|
|
243
|
+
end
|
|
244
|
+
result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd) do |sink, completed_result|
|
|
245
|
+
record_completed_pty_output(sink, completed_result)
|
|
246
|
+
end
|
|
247
|
+
refresh_composer_status
|
|
248
|
+
result
|
|
148
249
|
rescue Errno::ENOENT => e
|
|
149
250
|
runtime_output("Error: #{e.message}")
|
|
251
|
+
nil
|
|
150
252
|
end
|
|
151
253
|
|
|
152
|
-
def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd
|
|
254
|
+
def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, &on_complete)
|
|
153
255
|
runner = InteractivePtyRunner.new
|
|
154
|
-
|
|
256
|
+
run_with_sink = lambda do |input, sink|
|
|
257
|
+
result = runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, sink: sink)
|
|
258
|
+
on_complete.call(sink, result) if on_complete
|
|
259
|
+
result
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if @prompt.respond_to?(:with_inline_terminal_handoff)
|
|
263
|
+
@prompt.with_inline_terminal_handoff do |input, output, transition|
|
|
264
|
+
sink = AdaptivePtyOutputSink.new(
|
|
265
|
+
output: output,
|
|
266
|
+
on_exclusive: transition,
|
|
267
|
+
max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
|
|
268
|
+
)
|
|
269
|
+
run_with_sink.call(input, sink)
|
|
270
|
+
end
|
|
271
|
+
elsif @prompt.respond_to?(:with_terminal_handoff)
|
|
155
272
|
@prompt.with_terminal_handoff do |input, output|
|
|
156
|
-
|
|
273
|
+
sink = PassthroughPtyOutputSink.new(
|
|
274
|
+
output: output,
|
|
275
|
+
max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
|
|
276
|
+
)
|
|
277
|
+
run_with_sink.call(input, sink)
|
|
157
278
|
end
|
|
158
279
|
else
|
|
159
|
-
|
|
280
|
+
sink = PassthroughPtyOutputSink.new(
|
|
281
|
+
output: $stdout,
|
|
282
|
+
max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
|
|
283
|
+
)
|
|
284
|
+
run_with_sink.call($stdin, sink)
|
|
160
285
|
end
|
|
161
286
|
end
|
|
162
287
|
|
|
163
|
-
def
|
|
288
|
+
def record_completed_pty_output(sink, result)
|
|
289
|
+
classified_output = sink.respond_to?(:transcript_safe?)
|
|
290
|
+
return if classified_output && !sink.transcript_safe?
|
|
291
|
+
|
|
292
|
+
output = sink&.captured_output || +"".b
|
|
293
|
+
allow_input = classified_output &&
|
|
294
|
+
sink.respond_to?(:pre_input_capture_only?) &&
|
|
295
|
+
sink.pre_input_capture_only?
|
|
296
|
+
transcript_output = terminal_transcript_output(
|
|
297
|
+
output,
|
|
298
|
+
result,
|
|
299
|
+
truncated: sink&.truncated? || false,
|
|
300
|
+
allow_input: allow_input,
|
|
301
|
+
normalize_line_controls: classified_output
|
|
302
|
+
)
|
|
303
|
+
return unless transcript_output
|
|
304
|
+
|
|
305
|
+
record_tab_transient_shell_output(transcript_output, render: false)
|
|
306
|
+
if @prompt.respond_to?(:record_transient_terminal_output)
|
|
307
|
+
@prompt.record_transient_terminal_output(transcript_output, render: false)
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def terminal_transcript_output(
|
|
312
|
+
output,
|
|
313
|
+
result,
|
|
314
|
+
truncated:,
|
|
315
|
+
allow_input: false,
|
|
316
|
+
normalize_line_controls: false
|
|
317
|
+
)
|
|
318
|
+
return if truncated || (result.input_forwarded && !allow_input)
|
|
319
|
+
|
|
320
|
+
text = ANSI.normalize_transcript_encoding(output).gsub("\r\n", "\n")
|
|
321
|
+
return if text.empty?
|
|
322
|
+
|
|
323
|
+
if normalize_line_controls
|
|
324
|
+
sanitized = PtyTranscriptNormalizer.normalize(text)
|
|
325
|
+
else
|
|
326
|
+
return if text.include?("\r")
|
|
327
|
+
|
|
328
|
+
sanitized = ANSI.sanitize_transcript(text)
|
|
329
|
+
return unless sanitized == text
|
|
330
|
+
end
|
|
331
|
+
return if sanitized.empty?
|
|
332
|
+
return if ANSI.strip_control_sequences(sanitized).match?(UNSAFE_TRANSCRIPT_CONTROL_PATTERN)
|
|
333
|
+
|
|
334
|
+
sanitized
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def interactive_pty_environment(configured_env, preserve_git_pager: false)
|
|
164
338
|
ENV.to_h.merge(configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s)).tap do |env|
|
|
165
|
-
env.delete("GIT_PAGER") if env["GIT_PAGER"] == "cat"
|
|
339
|
+
env.delete("GIT_PAGER") if !preserve_git_pager && env["GIT_PAGER"] == "cat"
|
|
166
340
|
env["TERM"] = "xterm-256color" if env["TERM"].to_s.empty? || env["TERM"] == "dumb"
|
|
167
341
|
end
|
|
168
342
|
end
|
|
@@ -191,8 +365,13 @@ module Kward
|
|
|
191
365
|
break if input.nil?
|
|
192
366
|
|
|
193
367
|
result = run_ekwsh_command(shell, input)
|
|
194
|
-
|
|
195
|
-
|
|
368
|
+
if result.clear
|
|
369
|
+
clear_active_tab_transient_shell_output
|
|
370
|
+
@prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
|
|
371
|
+
else
|
|
372
|
+
record_tab_transient_shell_output(result.output, render: false) unless result.interactive_command
|
|
373
|
+
@prompt.say(result.output) unless result.streamed || result.interactive_command || result.output.to_s.empty?
|
|
374
|
+
end
|
|
196
375
|
return :tab_action if pending_tab_action?
|
|
197
376
|
|
|
198
377
|
if result.open_editor_path
|
|
@@ -249,14 +428,13 @@ module Kward
|
|
|
249
428
|
end
|
|
250
429
|
|
|
251
430
|
def run_ekwsh_interactive_pty_command(shell, result)
|
|
252
|
-
|
|
253
|
-
pty_result = run_interactive_pty_with_terminal_handoff(
|
|
254
|
-
shell.command_shell,
|
|
431
|
+
run_user_interactive_pty_command(
|
|
255
432
|
result.interactive_command,
|
|
433
|
+
shell: shell.command_shell,
|
|
256
434
|
env: shell.child_env(interactive: true),
|
|
257
|
-
cwd: shell.cwd
|
|
435
|
+
cwd: shell.cwd,
|
|
436
|
+
intro: result.output
|
|
258
437
|
)
|
|
259
|
-
@prompt.say("[interactive PTY session exited with status #{pty_result.exit_status}]\n") if @prompt.respond_to?(:say)
|
|
260
438
|
end
|
|
261
439
|
|
|
262
440
|
def run_ekwsh_command(shell, input)
|
|
@@ -306,6 +484,21 @@ module Kward
|
|
|
306
484
|
result
|
|
307
485
|
end
|
|
308
486
|
|
|
487
|
+
def record_tab_transient_shell_output(text, render: true)
|
|
488
|
+
value = text.to_s
|
|
489
|
+
return if value.empty?
|
|
490
|
+
|
|
491
|
+
tab = active_tab if respond_to?(:active_tab, true)
|
|
492
|
+
tab&.append_transient_shell_entry(value)
|
|
493
|
+
if render && @prompt.respond_to?(:record_transient_terminal_output)
|
|
494
|
+
@prompt.record_transient_terminal_output(value)
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def clear_active_tab_transient_shell_output
|
|
499
|
+
clear_active_tab_transient_shell_entries if respond_to?(:clear_active_tab_transient_shell_entries, true)
|
|
500
|
+
end
|
|
501
|
+
|
|
309
502
|
def drain_ekwsh_chunks(chunks)
|
|
310
503
|
loop do
|
|
311
504
|
@prompt.write_transcript_delta(chunks.pop(true))
|
|
@@ -422,6 +615,14 @@ module Kward
|
|
|
422
615
|
@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
|
|
423
616
|
end
|
|
424
617
|
|
|
618
|
+
def refresh_composer_status
|
|
619
|
+
if @prompt.respond_to?(:refresh_composer_status)
|
|
620
|
+
@prompt.refresh_composer_status
|
|
621
|
+
elsif @prompt.respond_to?(:redraw)
|
|
622
|
+
@prompt.redraw
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
425
626
|
def reload_client_config
|
|
426
627
|
@client.reload_config if @client.respond_to?(:reload_config)
|
|
427
628
|
end
|
data/lib/kward/cli/sessions.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Kward
|
|
|
21
21
|
path = session_store.remembered_last_session_path if session_store.respond_to?(:remembered_last_session_path)
|
|
22
22
|
return nil if path.to_s.empty?
|
|
23
23
|
|
|
24
|
-
@active_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)
|
|
24
|
+
@active_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, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
|
|
25
25
|
reset_session_diff(@active_session.path)
|
|
26
26
|
track_session(@active_session)
|
|
27
27
|
@resumed_last_session = true
|
|
@@ -99,6 +99,7 @@ module Kward
|
|
|
99
99
|
def start_new_session(session_store)
|
|
100
100
|
return say_sessions_unavailable unless session_store
|
|
101
101
|
|
|
102
|
+
prepare_interactive_project_skills if respond_to?(:prepare_interactive_project_skills, true)
|
|
102
103
|
previous_session = @active_session
|
|
103
104
|
@active_session = track_session(session_store.create)
|
|
104
105
|
reset_session_diff
|
|
@@ -127,7 +128,7 @@ module Kward
|
|
|
127
128
|
|
|
128
129
|
def load_session(session_store, path, message: nil)
|
|
129
130
|
previous_session = @active_session
|
|
130
|
-
@active_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
|
+
@active_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, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
|
|
131
132
|
reset_session_diff(@active_session.path)
|
|
132
133
|
track_session(@active_session)
|
|
133
134
|
cleanup_replaced_session(previous_session)
|
|
@@ -396,7 +397,8 @@ module Kward
|
|
|
396
397
|
workspace: configured_workspace(root: session_store.cwd),
|
|
397
398
|
provider: current_model_provider,
|
|
398
399
|
model: current_model_id,
|
|
399
|
-
reasoning_effort: current_reasoning_effort
|
|
400
|
+
reasoning_effort: current_reasoning_effort,
|
|
401
|
+
project_skill_paths: project_skill_paths_for(session_store.cwd) || []
|
|
400
402
|
)
|
|
401
403
|
reset_session_diff(@active_session.path)
|
|
402
404
|
track_session(@active_session)
|
|
@@ -579,7 +581,7 @@ module Kward
|
|
|
579
581
|
end
|
|
580
582
|
|
|
581
583
|
def clone_session_file_from_path(session_store, path)
|
|
582
|
-
source_session, source_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)
|
|
584
|
+
source_session, source_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, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
|
|
583
585
|
clone, = session_store.create_independent_from_conversation(source_conversation, parent_session: source_session)
|
|
584
586
|
clone.path
|
|
585
587
|
end
|
data/lib/kward/cli/settings.rb
CHANGED
|
@@ -115,7 +115,7 @@ module Kward
|
|
|
115
115
|
ConfigFiles.update_config("provider" => ModelInfo.config_provider_for_provider(provider))
|
|
116
116
|
reload_client_config
|
|
117
117
|
refresh_conversation_runtime(conversation)
|
|
118
|
-
|
|
118
|
+
refresh_composer_status
|
|
119
119
|
end
|
|
120
120
|
|
|
121
121
|
def provider_choices
|
|
@@ -166,7 +166,7 @@ module Kward
|
|
|
166
166
|
ConfigFiles.update_config("local_backend" => backend, "local_base_url" => url)
|
|
167
167
|
reload_client_config
|
|
168
168
|
refresh_conversation_runtime(conversation)
|
|
169
|
-
|
|
169
|
+
refresh_composer_status
|
|
170
170
|
end
|
|
171
171
|
|
|
172
172
|
def configure_account_settings
|
|
@@ -920,7 +920,7 @@ module Kward
|
|
|
920
920
|
ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
|
|
921
921
|
reload_client_config
|
|
922
922
|
refresh_conversation_runtime(conversation)
|
|
923
|
-
|
|
923
|
+
refresh_composer_status
|
|
924
924
|
end
|
|
925
925
|
|
|
926
926
|
REASONING_CONFIG_DEBOUNCE_SECONDS = 0.5
|
|
@@ -953,18 +953,10 @@ module Kward
|
|
|
953
953
|
queue_reasoning_config(effort, provider: provider, conversation: conversation) if queue_config
|
|
954
954
|
if queue_config
|
|
955
955
|
update_conversation_reasoning_effort(conversation, effort)
|
|
956
|
-
|
|
956
|
+
refresh_composer_status
|
|
957
957
|
else
|
|
958
958
|
refresh_conversation_runtime(conversation, reasoning_effort: effort)
|
|
959
|
-
|
|
960
|
-
end
|
|
961
|
-
end
|
|
962
|
-
|
|
963
|
-
def refresh_reasoning_status
|
|
964
|
-
if @prompt.respond_to?(:refresh_composer_status)
|
|
965
|
-
@prompt.refresh_composer_status
|
|
966
|
-
else
|
|
967
|
-
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
959
|
+
refresh_composer_status
|
|
968
960
|
end
|
|
969
961
|
end
|
|
970
962
|
|
|
@@ -32,6 +32,9 @@ module Kward
|
|
|
32
32
|
run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
|
|
33
33
|
end
|
|
34
34
|
[true, nil]
|
|
35
|
+
when "skills"
|
|
36
|
+
run_busy_local_command_and_requeue { handle_project_skills_command(argument) }
|
|
37
|
+
[true, nil]
|
|
35
38
|
when "redraw"
|
|
36
39
|
run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
|
|
37
40
|
[true, nil]
|
|
@@ -47,6 +50,9 @@ module Kward
|
|
|
47
50
|
when "shell"
|
|
48
51
|
run_ekwsh(agent)
|
|
49
52
|
[true, nil]
|
|
53
|
+
when "capture"
|
|
54
|
+
run_captured_shell_command(argument, agent)
|
|
55
|
+
[true, nil]
|
|
50
56
|
when "scratchpad"
|
|
51
57
|
open_scratchpad_command(argument)
|
|
52
58
|
[true, nil]
|
data/lib/kward/cli/tabs.rb
CHANGED
|
@@ -35,10 +35,13 @@ module Kward
|
|
|
35
35
|
:unread,
|
|
36
36
|
:pending_question,
|
|
37
37
|
:shell,
|
|
38
|
+
:transient_shell_entries,
|
|
38
39
|
:error_reported,
|
|
39
40
|
:local_busy_activity,
|
|
40
41
|
keyword_init: true
|
|
41
42
|
) do
|
|
43
|
+
TRANSIENT_SHELL_OUTPUT_LIMIT = 200_000
|
|
44
|
+
|
|
42
45
|
def running?
|
|
43
46
|
%w[queued running waiting_for_question].include?(status.to_s)
|
|
44
47
|
end
|
|
@@ -54,6 +57,41 @@ module Kward
|
|
|
54
57
|
def record_event(event)
|
|
55
58
|
event_history << event
|
|
56
59
|
end
|
|
60
|
+
|
|
61
|
+
def append_transient_shell_entry(text)
|
|
62
|
+
value = text.to_s
|
|
63
|
+
return if value.empty?
|
|
64
|
+
|
|
65
|
+
self.transient_shell_entries ||= []
|
|
66
|
+
transient_shell_entries << value
|
|
67
|
+
trim_transient_shell_entries
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def clear_transient_shell_entries
|
|
71
|
+
self.transient_shell_entries = []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def trim_transient_shell_entries
|
|
77
|
+
combined = transient_shell_entries.join
|
|
78
|
+
return if combined.length <= TRANSIENT_SHELL_OUTPUT_LIMIT
|
|
79
|
+
|
|
80
|
+
target = (TRANSIENT_SHELL_OUTPUT_LIMIT * 0.9).floor
|
|
81
|
+
cutoff = combined.length - target
|
|
82
|
+
newline = combined.index("\n", cutoff)
|
|
83
|
+
cutoff = newline + 1 if newline
|
|
84
|
+
cutoff = transient_shell_escape_boundary(combined, cutoff)
|
|
85
|
+
self.transient_shell_entries = [combined[cutoff..].to_s]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def transient_shell_escape_boundary(text, cutoff)
|
|
89
|
+
escape_start = text.rindex("\e", cutoff)
|
|
90
|
+
return cutoff unless escape_start
|
|
91
|
+
|
|
92
|
+
escape = ANSI.escape_sequence_at(text, escape_start)
|
|
93
|
+
escape && escape_start + escape.length > cutoff ? escape_start + escape.length : cutoff
|
|
94
|
+
end
|
|
57
95
|
end
|
|
58
96
|
|
|
59
97
|
class TabQuestionPrompt
|
|
@@ -194,7 +232,7 @@ module Kward
|
|
|
194
232
|
|
|
195
233
|
def restore_tab_session(session_store, path, workspace_root: session_store.cwd, strict: false)
|
|
196
234
|
if File.file?(path)
|
|
197
|
-
session, conversation = session_store.load(path, workspace: configured_workspace(root: workspace_root, strict: strict), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
235
|
+
session, conversation = session_store.load(path, workspace: configured_workspace(root: workspace_root, strict: strict), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(workspace_root) || [])
|
|
198
236
|
return [track_session(session), conversation]
|
|
199
237
|
end
|
|
200
238
|
|
|
@@ -215,9 +253,11 @@ module Kward
|
|
|
215
253
|
git_committer = if strict
|
|
216
254
|
->(message:, paths:) { git_commit_for_agent(workspace.root.to_s, message: message, paths: paths) }
|
|
217
255
|
end
|
|
256
|
+
project_skill_paths = project_skill_paths_for(conversation.workspace_root) || []
|
|
218
257
|
tool_registry = ToolRegistry.new(
|
|
219
258
|
workspace: workspace,
|
|
220
259
|
prompt: prompt,
|
|
260
|
+
skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths),
|
|
221
261
|
tool_approval: tab_tool_approval_callback(prompt),
|
|
222
262
|
hook_manager: hook_manager,
|
|
223
263
|
hook_context: hook_context,
|
|
@@ -277,6 +317,7 @@ module Kward
|
|
|
277
317
|
unread: false,
|
|
278
318
|
pending_question: nil,
|
|
279
319
|
shell: nil,
|
|
320
|
+
transient_shell_entries: [],
|
|
280
321
|
error_reported: false,
|
|
281
322
|
local_busy_activity: nil
|
|
282
323
|
).tap { |tab| assign_tab_question_prompt(agent, tab) }
|
|
@@ -422,6 +463,7 @@ module Kward
|
|
|
422
463
|
tab.queued_inputs.clear
|
|
423
464
|
tab.steering = nil
|
|
424
465
|
tab.shell = nil
|
|
466
|
+
tab.clear_transient_shell_entries
|
|
425
467
|
tab.stream_state = new_tab_stream_state(tab.driver)
|
|
426
468
|
tab.markdown_chunks.clear
|
|
427
469
|
update_prompt_workspace_root(current_workspace_root)
|
|
@@ -449,7 +491,7 @@ module Kward
|
|
|
449
491
|
end
|
|
450
492
|
return snapshot_method.call unless supports_transcript_option
|
|
451
493
|
|
|
452
|
-
snapshot_method.call(include_transcript: tab.running?
|
|
494
|
+
snapshot_method.call(include_transcript: tab.running?)
|
|
453
495
|
end
|
|
454
496
|
|
|
455
497
|
def activate_tab(index, render: true)
|
|
@@ -482,8 +524,8 @@ module Kward
|
|
|
482
524
|
tab.agent
|
|
483
525
|
end
|
|
484
526
|
|
|
485
|
-
def render_tab(tab)
|
|
486
|
-
if tab.snapshot && @prompt.respond_to?(:restore_tab_view_snapshot) &&
|
|
527
|
+
def render_tab(tab, restore_composer: true)
|
|
528
|
+
if restore_composer && tab.snapshot && @prompt.respond_to?(:restore_tab_view_snapshot) && tab.running?
|
|
487
529
|
@prompt.restore_tab_view_snapshot(tab.snapshot)
|
|
488
530
|
return
|
|
489
531
|
end
|
|
@@ -495,8 +537,23 @@ module Kward
|
|
|
495
537
|
render_transcript_messages(tab.driver.messages)
|
|
496
538
|
end
|
|
497
539
|
report_tab_runtime_error(tab) if %w[failed cancelled].include?(tab.status.to_s)
|
|
540
|
+
render_tab_transient_shell_entries(tab)
|
|
498
541
|
end
|
|
499
|
-
restore_tab_composer_snapshot(tab.snapshot)
|
|
542
|
+
restore_tab_composer_snapshot(tab.snapshot) if restore_composer
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def render_tab_transient_shell_entries(tab)
|
|
546
|
+
Array(tab.transient_shell_entries).each do |entry|
|
|
547
|
+
if @prompt.respond_to?(:say)
|
|
548
|
+
@prompt.say(entry)
|
|
549
|
+
elsif @prompt.respond_to?(:write_transcript)
|
|
550
|
+
@prompt.write_transcript(entry)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def clear_active_tab_transient_shell_entries
|
|
556
|
+
active_tab&.clear_transient_shell_entries
|
|
500
557
|
end
|
|
501
558
|
|
|
502
559
|
def restore_tab_composer_snapshot(snapshot)
|
data/lib/kward/cli.rb
CHANGED
|
@@ -16,6 +16,8 @@ require_relative "events"
|
|
|
16
16
|
require_relative "export_path"
|
|
17
17
|
require_relative "ekwsh"
|
|
18
18
|
require_relative "interactive_pty_runner"
|
|
19
|
+
require_relative "adaptive_pty_output_sink"
|
|
20
|
+
require_relative "pty_transcript_normalizer"
|
|
19
21
|
require_relative "auth/anthropic_oauth"
|
|
20
22
|
require_relative "auth/api_key_store"
|
|
21
23
|
require_relative "auth/github_oauth"
|
|
@@ -59,6 +61,8 @@ require_relative "cli/sysprompt"
|
|
|
59
61
|
require_relative "cli/stats"
|
|
60
62
|
require_relative "cli/openrouter_commands"
|
|
61
63
|
require_relative "cli/runtime_helpers"
|
|
64
|
+
require_relative "cli/project_skills"
|
|
65
|
+
require_relative "cli/project_skills_commands"
|
|
62
66
|
require_relative "cli/slash_commands"
|
|
63
67
|
require_relative "cli/memory_commands"
|
|
64
68
|
require_relative "cli/hook_commands"
|
|
@@ -94,6 +98,8 @@ module Kward
|
|
|
94
98
|
include CLI::Stats
|
|
95
99
|
include CLI::OpenRouterCommands
|
|
96
100
|
include CLI::RuntimeHelpers
|
|
101
|
+
include CLI::ProjectSkills
|
|
102
|
+
include CLI::ProjectSkillsCommands
|
|
97
103
|
include CLI::SlashCommands
|
|
98
104
|
include CLI::MemoryCommands
|
|
99
105
|
include CLI::HookCommands
|
|
@@ -232,6 +238,16 @@ module Kward
|
|
|
232
238
|
return
|
|
233
239
|
end
|
|
234
240
|
|
|
241
|
+
if @argv.first == "skills"
|
|
242
|
+
if help_option_arguments?(@argv[1..] || [])
|
|
243
|
+
print_command_help("skills")
|
|
244
|
+
return
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
handle_project_skills_cli_command(@argv[1..] || [])
|
|
248
|
+
return
|
|
249
|
+
end
|
|
250
|
+
|
|
235
251
|
if @argv.first == "edit"
|
|
236
252
|
if help_option_arguments?(@argv[1..] || [])
|
|
237
253
|
print_command_help("edit")
|
|
@@ -430,7 +446,8 @@ module Kward
|
|
|
430
446
|
end
|
|
431
447
|
|
|
432
448
|
def interactive_loop(agent: nil)
|
|
433
|
-
setup_interactive_prompt
|
|
449
|
+
setup_interactive_prompt(defer_warnings: true)
|
|
450
|
+
prepare_interactive_project_skills
|
|
434
451
|
session_store = interactive_session_store(agent)
|
|
435
452
|
@resumed_last_session = false
|
|
436
453
|
if session_store && @prompt.respond_to?(:update_tabs)
|
|
@@ -450,8 +467,10 @@ module Kward
|
|
|
450
467
|
|
|
451
468
|
print_visual_banner unless @resumed_last_session || @restored_tabs
|
|
452
469
|
render_resumed_last_session_transcript(agent.conversation) if @resumed_last_session
|
|
470
|
+
enable_interactive_warnings if respond_to?(:enable_interactive_warnings, true)
|
|
453
471
|
|
|
454
472
|
@pending_inputs = []
|
|
473
|
+
install_bang_completion_provider(agent)
|
|
455
474
|
|
|
456
475
|
loop do
|
|
457
476
|
if @pending_inputs.empty? && active_tab&.shell
|
|
@@ -543,6 +562,7 @@ module Kward
|
|
|
543
562
|
runtime_output("Goodbye.")
|
|
544
563
|
active_tab&.agent&.conversation || agent&.conversation
|
|
545
564
|
ensure
|
|
565
|
+
clear_bang_completion_provider
|
|
546
566
|
begin
|
|
547
567
|
stop_tabs if respond_to?(:stop_tabs, true)
|
|
548
568
|
@prompt.close if prompt_interface?
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -917,8 +917,12 @@ module Kward
|
|
|
917
917
|
# Lists configured skills discovered under the config directory.
|
|
918
918
|
#
|
|
919
919
|
# @return [Array<Skill>] skill metadata available to the model
|
|
920
|
-
def skills(workspace_root: Dir.pwd, warning_sink: nil)
|
|
921
|
-
skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).skills
|
|
920
|
+
def skills(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
|
|
921
|
+
skills_registry(workspace_root: workspace_root, warning_sink: warning_sink, project_skill_paths: project_skill_paths).skills
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
def project_skill_candidates(workspace_root: Dir.pwd, warning_sink: nil)
|
|
925
|
+
skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).project_skill_candidates
|
|
922
926
|
end
|
|
923
927
|
|
|
924
928
|
# @return [String] trusted user plugin directory
|
|
@@ -961,7 +965,7 @@ module Kward
|
|
|
961
965
|
skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
|
|
962
966
|
end
|
|
963
967
|
|
|
964
|
-
def skills_registry(workspace_root: Dir.pwd, warning_sink: nil)
|
|
968
|
+
def skills_registry(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
|
|
965
969
|
Skills::Registry.new(
|
|
966
970
|
config_dir: config_dir,
|
|
967
971
|
workspace_root: workspace_root,
|
|
@@ -970,7 +974,8 @@ module Kward
|
|
|
970
974
|
max_file_bytes: MAX_SKILL_FILE_BYTES,
|
|
971
975
|
markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
|
|
972
976
|
inside_directory: method(:inside_directory?),
|
|
973
|
-
warning_sink: warning_sink || @warning_sink
|
|
977
|
+
warning_sink: warning_sink || @warning_sink,
|
|
978
|
+
project_skill_paths: project_skill_paths
|
|
974
979
|
)
|
|
975
980
|
end
|
|
976
981
|
|