kward 0.80.1 → 0.81.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/doc/composer.md +5 -8
  6. data/doc/configuration.md +13 -3
  7. data/doc/permissions.md +1 -1
  8. data/doc/rpc.md +2 -1
  9. data/doc/sandboxing.md +4 -4
  10. data/doc/security.md +6 -9
  11. data/doc/shell.md +161 -196
  12. data/doc/skills.md +17 -5
  13. data/doc/tabs.md +1 -1
  14. data/doc/usage.md +6 -5
  15. data/lib/kward/ansi.rb +9 -1
  16. data/lib/kward/cli/commands.rb +7 -0
  17. data/lib/kward/cli/interactive_turn.rb +1 -1
  18. data/lib/kward/cli/plugins.rb +1 -1
  19. data/lib/kward/cli/project_skills.rb +99 -0
  20. data/lib/kward/cli/project_skills_commands.rb +87 -0
  21. data/lib/kward/cli/prompt_interface.rb +18 -4
  22. data/lib/kward/cli/rendering.rb +28 -2
  23. data/lib/kward/cli/runtime_helpers.rb +156 -19
  24. data/lib/kward/cli/sessions.rb +6 -4
  25. data/lib/kward/cli/settings.rb +1 -1
  26. data/lib/kward/cli/slash_commands.rb +6 -0
  27. data/lib/kward/cli/tabs.rb +3 -1
  28. data/lib/kward/cli.rb +19 -1
  29. data/lib/kward/config_files.rb +9 -4
  30. data/lib/kward/conversation.rb +8 -5
  31. data/lib/kward/ekwsh.rb +37 -16
  32. data/lib/kward/interactive_pty_runner.rb +88 -22
  33. data/lib/kward/prompt_interface/composer_controller.rb +13 -1
  34. data/lib/kward/prompt_interface/editor/controller.rb +4 -0
  35. data/lib/kward/prompt_interface/key_handler.rb +78 -43
  36. data/lib/kward/prompt_interface/overlay_renderer.rb +12 -0
  37. data/lib/kward/prompt_interface/screen.rb +5 -0
  38. data/lib/kward/prompt_interface.rb +93 -21
  39. data/lib/kward/prompts/commands.rb +2 -0
  40. data/lib/kward/prompts.rb +6 -6
  41. data/lib/kward/rpc/server.rb +1 -0
  42. data/lib/kward/session_store.rb +3 -2
  43. data/lib/kward/skills/registry.rb +52 -2
  44. data/lib/kward/skills/trust_coordinator.rb +45 -0
  45. data/lib/kward/skills/trust_store.rb +107 -0
  46. data/lib/kward/version.rb +1 -1
  47. metadata +5 -1
@@ -8,7 +8,6 @@ module Kward
8
8
 
9
9
  def run_interactive_turn(agent, input, display_input: nil)
10
10
  prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
11
- print_user_transcript(input, display_input: display_input) if prompt_interface?
12
11
  return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
13
12
 
14
13
  queued_inputs = []
@@ -27,6 +26,7 @@ module Kward
27
26
  answer = nil
28
27
  error = nil
29
28
  @prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
29
+ print_user_transcript(input, display_input: display_input)
30
30
 
31
31
  worker = Thread.new do
32
32
  options = agent_display_options(display_input)
@@ -122,7 +122,7 @@ module Kward
122
122
  argument_hint: template.argument_hint
123
123
  }
124
124
  end
125
- skill_entries = ConfigFiles.skills.map do |skill|
125
+ skill_entries = ConfigFiles.skills(workspace_root: current_workspace_root, project_skill_paths: @interactive_project_skill_paths || []).map do |skill|
126
126
  {
127
127
  name: "skill:#{skill.name}",
128
128
  description: skill.description,
@@ -0,0 +1,99 @@
1
+ require "pathname"
2
+ require_relative "../skills/trust_coordinator"
3
+ require_relative "../skills/trust_store"
4
+
5
+ # Namespace for CLI orchestration helpers.
6
+ module Kward
7
+ class CLI
8
+ # Coordinates interactive trust decisions for project-provided Agent Skills.
9
+ module ProjectSkills
10
+ private
11
+
12
+ def prepare_interactive_project_skills
13
+ return unless prompt_interface?
14
+
15
+ workspace_root = current_workspace_root
16
+ candidates = ConfigFiles.project_skill_candidates(workspace_root: workspace_root)
17
+ trust_store = Skills::TrustStore.new(config_dir: ConfigFiles.config_dir)
18
+ coordinator = Skills::TrustCoordinator.new(workspace_root: workspace_root, trust_store: trust_store)
19
+
20
+ if ConfigFiles.project_skills_trusted?
21
+ @interactive_project_skill_paths = candidates.map(&:path)
22
+ else
23
+ pending = coordinator.pending(candidates)
24
+ unless pending.empty?
25
+ decision = prompt_for_project_skill_trust(pending)
26
+ coordinator.record!(pending, decision)
27
+ end
28
+
29
+ @interactive_project_skill_paths = coordinator.allowed_paths(candidates)
30
+ end
31
+ end
32
+
33
+ def prompt_for_project_skill_trust(candidates)
34
+ paths = candidates.map { |candidate| " #{relative_workspace_path(candidate.path)}" }
35
+ message = (["Project skills found in #{current_workspace_root}:", *paths, "", "These files contain instructions that may influence the model."]).join("\n")
36
+ loop do
37
+ choice = @prompt.select(message, ["Allow", "Deny", "Review"], title: "Trust project skills")
38
+ case choice.to_s.downcase
39
+ when "review"
40
+ review_project_skills(candidates)
41
+ when "allow"
42
+ return "allow"
43
+ else
44
+ return "deny"
45
+ end
46
+ end
47
+ end
48
+
49
+ def review_project_skills(candidates)
50
+ candidates.each do |candidate|
51
+ content = read_project_skill_for_review(candidate.path)
52
+ resources = project_skill_resources(candidate.path)
53
+ details = [
54
+ "Project skill: #{relative_workspace_path(candidate.path)}",
55
+ resources.empty? ? nil : "Referenced resources:\n#{resources.map { |path| " #{path}" }.join("\n")}",
56
+ "",
57
+ content
58
+ ].compact.join("\n")
59
+ @prompt.say("\n#{ANSI.strip(details)}\n")
60
+ end
61
+ end
62
+
63
+ def read_project_skill_for_review(path)
64
+ return "Unable to review: file is too large." if File.size(path) > ConfigFiles::MAX_SKILL_FILE_BYTES
65
+
66
+ ANSI.strip(File.read(path, ConfigFiles::MAX_SKILL_FILE_BYTES + 1))
67
+ rescue StandardError => e
68
+ "Unable to review: #{e.message}"
69
+ end
70
+
71
+ def project_skill_resources(path)
72
+ folder = File.dirname(path)
73
+ roots = %w[scripts references assets].map { |name| File.join(folder, name) }.select { |root| Dir.exist?(root) }
74
+ roots.flat_map { |root| Dir.glob(File.join(root, "**", "*")).select { |entry| File.file?(entry) } }.sort.first(200).map do |resource|
75
+ Pathname.new(resource).relative_path_from(Pathname.new(folder)).to_s
76
+ end
77
+ rescue StandardError
78
+ []
79
+ end
80
+
81
+ def project_skill_paths_for(workspace_root)
82
+ return unless @interactive_project_skill_paths
83
+ return unless canonical_path(workspace_root) == canonical_path(current_workspace_root)
84
+
85
+ @interactive_project_skill_paths
86
+ end
87
+
88
+ def canonical_path(path)
89
+ File.realpath(path)
90
+ rescue SystemCallError
91
+ File.expand_path(path)
92
+ end
93
+
94
+ def relative_workspace_path(path)
95
+ Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(File.expand_path(current_workspace_root))).to_s
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,87 @@
1
+ # Namespace for CLI orchestration helpers.
2
+ module Kward
3
+ class CLI
4
+ # Handles explicit project skill trust management commands.
5
+ module ProjectSkillsCommands
6
+ def handle_project_skills_cli_command(arguments)
7
+ argument = Array(arguments).join(" ").strip.downcase
8
+ _workspace_root, candidates, coordinator = project_skill_trust_coordinator
9
+ if candidates.empty? && argument != "untrust"
10
+ @prompt.say("No project skills found in the current workspace.")
11
+ return
12
+ end
13
+
14
+ case argument
15
+ when "", "status"
16
+ lines = candidates.empty? ? ["No project skills found in the current workspace."] : candidates.map { |candidate| "#{relative_workspace_path(candidate.path)}: #{coordinator.decision(candidate) || "needs review"}" }
17
+ @prompt.say(lines.join("\n"))
18
+ when "trust"
19
+ coordinator.record!(candidates, "allow")
20
+ @prompt.say("Project skills trusted for the current skill snapshots.")
21
+ when "untrust"
22
+ coordinator.remove_workspace!
23
+ @prompt.say("Project skill trust removed for this workspace.")
24
+ when "review"
25
+ review_project_skills(candidates)
26
+ else
27
+ raise ArgumentError, "Usage: kward skills [status|trust|untrust|review]"
28
+ end
29
+ end
30
+
31
+ def handle_project_skills_command(argument)
32
+ case argument.to_s.strip.downcase
33
+ when "", "status"
34
+ show_project_skills_status
35
+ when "trust"
36
+ trust_current_project_skills
37
+ when "untrust"
38
+ untrust_current_project_skills
39
+ else
40
+ runtime_output("Usage: /skills [status|trust|untrust]")
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def project_skill_trust_coordinator
47
+ workspace_root = current_workspace_root
48
+ candidates = ConfigFiles.project_skill_candidates(workspace_root: workspace_root)
49
+ store = Skills::TrustStore.new(config_dir: ConfigFiles.config_dir)
50
+ [workspace_root, candidates, Skills::TrustCoordinator.new(workspace_root: workspace_root, trust_store: store)]
51
+ end
52
+
53
+ def show_project_skills_status
54
+ _workspace_root, candidates, coordinator = project_skill_trust_coordinator
55
+ if candidates.empty?
56
+ runtime_output("No project skills found in the current workspace.")
57
+ return
58
+ end
59
+
60
+ lines = candidates.map do |candidate|
61
+ status = coordinator.decision(candidate) || "needs review"
62
+ "#{relative_workspace_path(candidate.path)}: #{status}"
63
+ end
64
+ runtime_output(lines.join("\n"))
65
+ end
66
+
67
+ def trust_current_project_skills
68
+ _workspace_root, candidates, coordinator = project_skill_trust_coordinator
69
+ if candidates.empty?
70
+ runtime_output("No project skills found in the current workspace.")
71
+ return
72
+ end
73
+
74
+ coordinator.record!(candidates, "allow")
75
+ @interactive_project_skill_paths = coordinator.allowed_paths(candidates)
76
+ runtime_output("Project skills trusted for the current skill snapshots. Use /new to rebuild the agent.")
77
+ end
78
+
79
+ def untrust_current_project_skills
80
+ _workspace_root, _candidates, coordinator = project_skill_trust_coordinator
81
+ coordinator.remove_workspace!
82
+ @interactive_project_skill_paths = []
83
+ runtime_output("Project skill trust removed for this workspace. Use /new to rebuild the agent.")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -8,7 +8,7 @@ module Kward
8
8
  module PromptInterfaceSupport
9
9
  private
10
10
 
11
- def setup_interactive_prompt
11
+ def setup_interactive_prompt(defer_warnings: false)
12
12
  return unless @stdin.tty?
13
13
  return unless @prompt.is_a?(TTY::Prompt)
14
14
 
@@ -16,6 +16,7 @@ module Kward
16
16
  return unless prompt_interface
17
17
 
18
18
  @interactive_warning_sink_active = true
19
+ @interactive_warning_output_ready = !defer_warnings
19
20
  ConfigFiles.warning_sink = interactive_warning_sink
20
21
  @prompt = prompt_interface.new(
21
22
  slash_commands: slash_command_entries,
@@ -43,13 +44,19 @@ module Kward
43
44
  editor_line_numbers: ConfigFiles.editor_line_numbers,
44
45
  editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers },
45
46
  diff_view: ConfigFiles.diff_view,
46
- diff_view_source: -> { ConfigFiles.diff_view }
47
+ diff_view_source: -> { ConfigFiles.diff_view },
48
+ redraw_handler: method(:redraw_interactive_prompt)
47
49
  )
48
50
  if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
49
51
  @prompt.start(render: false)
50
52
  else
51
53
  @prompt.start
52
54
  end
55
+ flush_interactive_warnings if @interactive_warning_output_ready
56
+ end
57
+
58
+ def enable_interactive_warnings
59
+ @interactive_warning_output_ready = true
53
60
  flush_interactive_warnings
54
61
  end
55
62
 
@@ -60,7 +67,7 @@ module Kward
60
67
  next
61
68
  end
62
69
 
63
- if prompt_interface?
70
+ if @interactive_warning_output_ready && prompt_interface?
64
71
  runtime_output(message)
65
72
  else
66
73
  (@pending_interactive_warnings ||= []) << message
@@ -100,6 +107,7 @@ module Kward
100
107
  def clear_interactive_warning_sink
101
108
  ConfigFiles.warning_sink = nil
102
109
  @interactive_warning_sink_active = false
110
+ @interactive_warning_output_ready = false
103
111
  @pending_interactive_warnings = nil
104
112
  @interactive_warning_sink = nil
105
113
  end
@@ -158,10 +166,16 @@ module Kward
158
166
  end
159
167
 
160
168
  def startup_plugins_value
161
- filenames = plugin_registry.paths.map { |path| File.basename(path) }
169
+ filenames = plugin_registry.paths.map { |path| startup_plugin_name(path) }
162
170
  filenames.empty? ? "none" : filenames.join(", ")
163
171
  end
164
172
 
173
+ def startup_plugin_name(path)
174
+ plugin_root = ConfigFiles.plugin_dir
175
+ prefix = "#{plugin_root}#{File::SEPARATOR}"
176
+ path.start_with?(prefix) ? path.delete_prefix(prefix) : File.basename(path)
177
+ end
178
+
165
179
  def startup_status_line(refresh_update_check: false)
166
180
  color = startup_update_notice(refresh: refresh_update_check) ? :yellow : :green
167
181
  "#{ANSI.colorize("●", color, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
@@ -10,6 +10,24 @@ module Kward
10
10
  render_transcript_messages(conversation.messages)
11
11
  end
12
12
 
13
+ def redraw_interactive_prompt
14
+ tab = active_tab if respond_to?(:active_tab, true)
15
+ if tab && (tab.running? || tab.shell)
16
+ @prompt.redraw if @prompt.respond_to?(:redraw)
17
+ return
18
+ end
19
+
20
+ conversation = tab&.agent&.conversation || @footer_conversation
21
+ unless conversation && @prompt.respond_to?(:restore_transcript)
22
+ @prompt.redraw if @prompt.respond_to?(:redraw)
23
+ return
24
+ end
25
+
26
+ restore_prompt_transcript do
27
+ tab ? render_transcript_messages(tab.driver.messages) : render_conversation_transcript(conversation)
28
+ end
29
+ end
30
+
13
31
  # Renders a transcript supplied by either a Kward conversation or a
14
32
  # plugin-owned tab driver.
15
33
  def render_transcript_messages(messages)
@@ -199,7 +217,7 @@ module Kward
199
217
  # Writes the user transcript output for the terminal CLI flow.
200
218
  def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
201
219
  visible_input = display_input.nil? ? input : display_input
202
- @prompt.say("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
220
+ write_prompt_transcript("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
203
221
  print_attachment_badges(input, references: attachment_references)
204
222
  print_pasted_images(input, image_parts: image_parts)
205
223
  end
@@ -209,7 +227,15 @@ module Kward
209
227
  badges = references ? Array(references).map { |reference| attachment_badge_text(reference) } : composer_attachment_badges(input)
210
228
  return if badges.empty?
211
229
 
212
- @prompt.say("#{badges.join("\n")}\n")
230
+ write_prompt_transcript("#{badges.join("\n")}\n")
231
+ end
232
+
233
+ def write_prompt_transcript(message)
234
+ if @prompt.respond_to?(:write_transcript)
235
+ @prompt.write_transcript(message)
236
+ else
237
+ @prompt.say(message)
238
+ end
213
239
  end
214
240
 
215
241
  def composer_attachment_badges(input, attachments = [])
@@ -4,10 +4,21 @@ 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
+ FROZEN_COMPOSER_COMMANDS = /\Agit\s+(?:fetch|ls-remote|push|remote|status)(?:\s|\z)/.freeze
10
+
7
11
  private
8
12
 
9
13
  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)
14
+ Conversation.new(
15
+ workspace_root: workspace_root,
16
+ provider: current_model_provider,
17
+ model: current_model_id,
18
+ reasoning_effort: current_reasoning_effort,
19
+ plugin_registry: plugin_registry,
20
+ project_skill_paths: project_skill_paths_for(workspace_root)
21
+ )
11
22
  end
12
23
 
13
24
  def update_assistant_prompt(conversation)
@@ -50,6 +61,7 @@ module Kward
50
61
  tool_registry = ToolRegistry.new(
51
62
  workspace: workspace,
52
63
  prompt: @prompt,
64
+ skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths_for(conversation.workspace_root)),
53
65
  tool_approval: interactive_tool_approval_callback,
54
66
  hook_manager: hook_manager,
55
67
  hook_context: hook_context
@@ -86,17 +98,90 @@ module Kward
86
98
  return true
87
99
  end
88
100
 
101
+ shell = bang_shell(agent)
102
+ editor_result = shell.editor_command_result(command)
103
+ if editor_result
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,82 @@ 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
+ if @prompt.respond_to?(:write_transcript)
239
+ @prompt.write_transcript(intro_message)
240
+ elsif @prompt.respond_to?(:say)
241
+ @prompt.say(intro_message)
242
+ end
243
+ output = +"".b
244
+ output_truncated = false
245
+ handoff_options = {}
246
+ handoff_options[:preserve_composer] = true if frozen_composer_command?(command)
247
+ result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd, **handoff_options) do |chunk|
248
+ remaining = MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES - output.bytesize
249
+ if chunk.bytesize > remaining
250
+ output << chunk.byteslice(0, remaining) if remaining.positive?
251
+ output_truncated = true
252
+ else
253
+ output << chunk
254
+ end
255
+ end
256
+ @prompt.refresh_composer_status if @prompt.respond_to?(:refresh_composer_status)
257
+ transcript_output = terminal_transcript_output(output, result, truncated: output_truncated)
258
+ if transcript_output && @prompt.respond_to?(:record_transient_terminal_output)
259
+ @prompt.record_transient_terminal_output(transcript_output)
260
+ end
261
+ result
148
262
  rescue Errno::ENOENT => e
149
263
  runtime_output("Error: #{e.message}")
264
+ nil
150
265
  end
151
266
 
152
- def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:)
267
+ def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, preserve_composer: false, &block)
153
268
  runner = InteractivePtyRunner.new
154
269
  if @prompt.respond_to?(:with_terminal_handoff)
155
- @prompt.with_terminal_handoff do |input, output|
156
- runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output)
270
+ if preserve_composer
271
+ @prompt.with_terminal_handoff(preserve_composer: true) do |input, output|
272
+ runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
273
+ end
274
+ else
275
+ @prompt.with_terminal_handoff do |input, output|
276
+ runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
277
+ end
157
278
  end
158
279
  else
159
- runner.run(shell, "-c", command, env: env, cwd: cwd)
280
+ runner.run(shell, "-c", command, env: env, cwd: cwd, &block)
160
281
  end
161
282
  end
162
283
 
163
- def interactive_pty_environment(configured_env)
284
+ def frozen_composer_command?(command)
285
+ FROZEN_COMPOSER_COMMANDS.match?(command.to_s.strip)
286
+ end
287
+
288
+ def terminal_transcript_output(output, result, truncated:)
289
+ return if truncated || result.input_forwarded
290
+
291
+ text = ANSI.normalize_transcript_encoding(output).gsub("\r\n", "\n")
292
+ return if text.empty? || text.include?("\r")
293
+
294
+ sanitized = ANSI.sanitize_transcript(text)
295
+ return unless sanitized == text
296
+ return if ANSI.strip_control_sequences(text).match?(UNSAFE_TRANSCRIPT_CONTROL_PATTERN)
297
+
298
+ sanitized
299
+ end
300
+
301
+ def interactive_pty_environment(configured_env, preserve_git_pager: false)
164
302
  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"
303
+ env.delete("GIT_PAGER") if !preserve_git_pager && env["GIT_PAGER"] == "cat"
166
304
  env["TERM"] = "xterm-256color" if env["TERM"].to_s.empty? || env["TERM"] == "dumb"
167
305
  end
168
306
  end
@@ -249,14 +387,13 @@ module Kward
249
387
  end
250
388
 
251
389
  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,
390
+ run_user_interactive_pty_command(
255
391
  result.interactive_command,
392
+ shell: shell.command_shell,
256
393
  env: shell.child_env(interactive: true),
257
- cwd: shell.cwd
394
+ cwd: shell.cwd,
395
+ intro: result.output
258
396
  )
259
- @prompt.say("[interactive PTY session exited with status #{pty_result.exit_status}]\n") if @prompt.respond_to?(:say)
260
397
  end
261
398
 
262
399
  def run_ekwsh_command(shell, input)
@@ -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
@@ -956,7 +956,7 @@ module Kward
956
956
  refresh_reasoning_status
957
957
  else
958
958
  refresh_conversation_runtime(conversation, reasoning_effort: effort)
959
- @prompt.redraw if @prompt.respond_to?(:redraw)
959
+ refresh_reasoning_status
960
960
  end
961
961
  end
962
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]
@@ -194,7 +194,7 @@ module Kward
194
194
 
195
195
  def restore_tab_session(session_store, path, workspace_root: session_store.cwd, strict: false)
196
196
  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)
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, project_skill_paths: project_skill_paths_for(workspace_root) || [])
198
198
  return [track_session(session), conversation]
199
199
  end
200
200
 
@@ -215,9 +215,11 @@ module Kward
215
215
  git_committer = if strict
216
216
  ->(message:, paths:) { git_commit_for_agent(workspace.root.to_s, message: message, paths: paths) }
217
217
  end
218
+ project_skill_paths = project_skill_paths_for(conversation.workspace_root) || []
218
219
  tool_registry = ToolRegistry.new(
219
220
  workspace: workspace,
220
221
  prompt: prompt,
222
+ skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths),
221
223
  tool_approval: tab_tool_approval_callback(prompt),
222
224
  hook_manager: hook_manager,
223
225
  hook_context: hook_context,