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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/Rakefile +44 -4
  6. data/doc/composer.md +6 -9
  7. data/doc/configuration.md +13 -3
  8. data/doc/files.md +8 -2
  9. data/doc/permissions.md +1 -1
  10. data/doc/releasing.md +71 -38
  11. data/doc/rpc.md +2 -1
  12. data/doc/sandboxing.md +4 -4
  13. data/doc/security.md +6 -9
  14. data/doc/shell.md +161 -196
  15. data/doc/skills.md +17 -5
  16. data/doc/tabs.md +1 -1
  17. data/doc/usage.md +6 -5
  18. data/kward.gemspec +1 -1
  19. data/lib/kward/adaptive_pty_output_sink.rb +183 -0
  20. data/lib/kward/ansi.rb +9 -1
  21. data/lib/kward/cli/commands.rb +7 -0
  22. data/lib/kward/cli/git.rb +5 -1
  23. data/lib/kward/cli/interactive_turn.rb +1 -1
  24. data/lib/kward/cli/plugins.rb +1 -1
  25. data/lib/kward/cli/project_skills.rb +99 -0
  26. data/lib/kward/cli/project_skills_commands.rb +87 -0
  27. data/lib/kward/cli/prompt_interface.rb +18 -4
  28. data/lib/kward/cli/rendering.rb +41 -3
  29. data/lib/kward/cli/runtime_helpers.rb +222 -21
  30. data/lib/kward/cli/sessions.rb +6 -4
  31. data/lib/kward/cli/settings.rb +5 -13
  32. data/lib/kward/cli/slash_commands.rb +6 -0
  33. data/lib/kward/cli/tabs.rb +62 -5
  34. data/lib/kward/cli.rb +21 -1
  35. data/lib/kward/config_files.rb +9 -4
  36. data/lib/kward/conversation.rb +8 -5
  37. data/lib/kward/ekwsh.rb +37 -16
  38. data/lib/kward/image_attachments.rb +98 -15
  39. data/lib/kward/interactive_pty_runner.rb +102 -30
  40. data/lib/kward/prompt_interface/composer_controller.rb +15 -3
  41. data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
  42. data/lib/kward/prompt_interface/editor/controller.rb +10 -6
  43. data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
  44. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  45. data/lib/kward/prompt_interface/git_prompt.rb +1 -1
  46. data/lib/kward/prompt_interface/key_handler.rb +100 -43
  47. data/lib/kward/prompt_interface/overlay_renderer.rb +13 -0
  48. data/lib/kward/prompt_interface/project_browser.rb +162 -3
  49. data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
  50. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  51. data/lib/kward/prompt_interface/screen.rb +40 -15
  52. data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
  53. data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
  54. data/lib/kward/prompt_interface.rb +167 -44
  55. data/lib/kward/prompts/commands.rb +2 -0
  56. data/lib/kward/prompts.rb +6 -6
  57. data/lib/kward/pty_output_sink.rb +45 -0
  58. data/lib/kward/pty_transcript_normalizer.rb +93 -0
  59. data/lib/kward/rpc/server.rb +1 -0
  60. data/lib/kward/session_catalog.rb +87 -0
  61. data/lib/kward/session_store.rb +97 -7
  62. data/lib/kward/skills/registry.rb +52 -2
  63. data/lib/kward/skills/trust_coordinator.rb +45 -0
  64. data/lib/kward/skills/trust_store.rb +107 -0
  65. data/lib/kward/terminal_image_support.rb +116 -0
  66. data/lib/kward/terminal_sequences.rb +43 -0
  67. data/lib/kward/version.rb +1 -1
  68. metadata +11 -4
  69. data/.github/workflows/ci.yml +0 -48
  70. 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(workspace_root: workspace_root, provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, plugin_registry: plugin_registry)
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 = configured_workspace(root: interactive_workspace_root(agent)).run_shell_command(command)
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
- env = interactive_pty_environment(config[:env])
144
- cwd = interactive_workspace_root(agent)
145
- @prompt.say("$ #{command}\n[interactive PTY session started]\n") if @prompt.respond_to?(:say)
146
- result = run_interactive_pty_with_terminal_handoff(config[:shell], command, env: env, cwd: cwd)
147
- @prompt.say("[interactive PTY session exited with status #{result.exit_status}]\n") if @prompt.respond_to?(:say)
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
- if @prompt.respond_to?(:with_terminal_handoff)
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
- runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output)
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
- runner.run(shell, "-c", command, env: env, cwd: cwd)
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 interactive_pty_environment(configured_env)
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
- @prompt.clear_transcript if result.clear && @prompt.respond_to?(:clear_transcript)
195
- @prompt.say(result.output) unless result.streamed || result.interactive_command || result.output.to_s.empty?
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
- @prompt.say(result.output) unless result.output.to_s.empty?
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
@@ -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
@@ -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
- @prompt.redraw if @prompt.respond_to?(:redraw)
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
- @prompt.redraw if @prompt.respond_to?(:redraw)
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
- @prompt.redraw if @prompt.respond_to?(:redraw)
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
- refresh_reasoning_status
956
+ refresh_composer_status
957
957
  else
958
958
  refresh_conversation_runtime(conversation, reasoning_effort: effort)
959
- @prompt.redraw if @prompt.respond_to?(:redraw)
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]
@@ -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? || !tab.shell.nil?)
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) && (tab.running? || tab.shell)
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?
@@ -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