kward 0.77.0 → 0.79.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +7 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +50 -0
  5. data/Gemfile.lock +2 -2
  6. data/README.md +5 -2
  7. data/doc/agent-tools.md +1 -1
  8. data/doc/code-search.md +9 -6
  9. data/doc/configuration.md +106 -1
  10. data/doc/extensibility.md +2 -0
  11. data/doc/getting-started.md +1 -1
  12. data/doc/local-models.md +130 -0
  13. data/doc/permissions.md +180 -0
  14. data/doc/plugins.md +58 -0
  15. data/doc/releasing.md +1 -1
  16. data/doc/rpc.md +73 -1
  17. data/doc/sandboxing.md +120 -0
  18. data/doc/security.md +15 -5
  19. data/doc/skills.md +10 -0
  20. data/doc/tabs.md +4 -3
  21. data/doc/web-search.md +4 -2
  22. data/doc/workspace-tools.md +3 -3
  23. data/kward.gemspec +1 -1
  24. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  25. data/lib/kward/auth/github_oauth.rb +3 -3
  26. data/lib/kward/auth/oauth_helpers.rb +4 -2
  27. data/lib/kward/auth/openai_oauth.rb +2 -1
  28. data/lib/kward/cli/doctor.rb +21 -0
  29. data/lib/kward/cli/plugins.rb +22 -0
  30. data/lib/kward/cli/rendering.rb +7 -1
  31. data/lib/kward/cli/runtime_helpers.rb +15 -1
  32. data/lib/kward/cli/settings.rb +66 -4
  33. data/lib/kward/cli/slash_commands.rb +109 -1
  34. data/lib/kward/cli/tabs.rb +140 -34
  35. data/lib/kward/cli.rb +31 -10
  36. data/lib/kward/config_files.rb +90 -1
  37. data/lib/kward/conversation.rb +14 -1
  38. data/lib/kward/hooks/http_handler.rb +2 -1
  39. data/lib/kward/http.rb +18 -0
  40. data/lib/kward/local_command_runner.rb +2 -2
  41. data/lib/kward/model/client.rb +173 -15
  42. data/lib/kward/model/model_info.rb +11 -1
  43. data/lib/kward/model/payloads.rb +7 -1
  44. data/lib/kward/model/stream_parser.rb +58 -26
  45. data/lib/kward/openrouter_model_cache.rb +2 -1
  46. data/lib/kward/pan/index.html.erb +50 -0
  47. data/lib/kward/pan/server.rb +49 -2
  48. data/lib/kward/permissions/policy.rb +171 -0
  49. data/lib/kward/plugin_registry.rb +54 -2
  50. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  51. data/lib/kward/prompt_interface/editor/controller.rb +36 -3
  52. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  53. data/lib/kward/prompt_interface.rb +20 -0
  54. data/lib/kward/prompts/commands.rb +2 -0
  55. data/lib/kward/prompts.rb +16 -5
  56. data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
  57. data/lib/kward/rpc/server.rb +76 -3
  58. data/lib/kward/rpc/session_manager.rb +43 -8
  59. data/lib/kward/rpc/transcript_normalizer.rb +14 -5
  60. data/lib/kward/sandbox/capabilities.rb +39 -0
  61. data/lib/kward/sandbox/command_runner.rb +28 -0
  62. data/lib/kward/sandbox/environment.rb +24 -0
  63. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  64. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  65. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  66. data/lib/kward/sandbox/policy.rb +74 -0
  67. data/lib/kward/sandbox/runner_factory.rb +55 -0
  68. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  69. data/lib/kward/sandbox.rb +9 -0
  70. data/lib/kward/session_store.rb +26 -0
  71. data/lib/kward/skills/capture.rb +144 -0
  72. data/lib/kward/starter_pack_installer.rb +3 -1
  73. data/lib/kward/tab_driver.rb +87 -0
  74. data/lib/kward/tab_store.rb +74 -12
  75. data/lib/kward/tools/code_search.rb +8 -2
  76. data/lib/kward/tools/fetch_content.rb +4 -2
  77. data/lib/kward/tools/fetch_raw.rb +3 -1
  78. data/lib/kward/tools/registry.rb +56 -8
  79. data/lib/kward/tools/search/code.rb +46 -12
  80. data/lib/kward/tools/search/web.rb +60 -17
  81. data/lib/kward/tools/search/web_fetch.rb +206 -38
  82. data/lib/kward/tools/web_search.rb +3 -1
  83. data/lib/kward/update_check.rb +2 -1
  84. data/lib/kward/version.rb +1 -1
  85. data/lib/kward/workspace.rb +18 -3
  86. data/lib/kward/workspace_factory.rb +17 -0
  87. data/templates/default/fulldoc/html/js/kward.js +1 -0
  88. data/templates/default/kward_navigation.rb +5 -2
  89. data/templates/default/layout/html/layout.erb +2 -0
  90. data/templates/default/layout/html/setup.rb +2 -0
  91. metadata +22 -2
@@ -13,11 +13,12 @@ require_relative "../plugin_registry"
13
13
  require_relative "../prompts/commands"
14
14
  require_relative "../rpc/transcript_normalizer"
15
15
  require_relative "../session_store"
16
+ require_relative "../skills/capture"
16
17
  require_relative "../session_trash"
17
18
  require_relative "../tools/tool_call"
18
19
  require_relative "../tools/registry"
19
20
  require_relative "../version"
20
- require_relative "../workspace"
21
+ require_relative "../workspace_factory"
21
22
 
22
23
  # Namespace for the Kward CLI agent runtime.
23
24
  module Kward
@@ -33,7 +34,11 @@ module Kward
33
34
  @client = client
34
35
  @output = output
35
36
  @udp_socket_class = udp_socket_class
36
- @workspace = Workspace.new(root: working_directory)
37
+ @workspace = WorkspaceFactory.build(
38
+ root: working_directory,
39
+ guardrails: ConfigFiles.workspace_guardrails_enabled?(config),
40
+ config: config
41
+ )
37
42
  @full_config = config
38
43
  @config = pan_config(config)
39
44
  @host = @config.fetch("host", DEFAULT_HOST).to_s
@@ -234,12 +239,18 @@ module Kward
234
239
  write_json(socket, 200, transcript: transcript_items, session: active_session_payload, workspace: @workspace.root.to_s)
235
240
  when ["GET", "/sessions"]
236
241
  write_json(socket, 200, sessions: session_payloads, activeSessionId: @session.id)
242
+ when ["GET", "/skill-capture/sessions"]
243
+ write_json(socket, 200, sessions: skill_capture_sessions)
237
244
  when ["GET", "/events"]
238
245
  stream_events(socket)
239
246
  when ["POST", "/turn"]
240
247
  handle_turn(socket, request[:body], request[:headers])
241
248
  when ["POST", "/sessions"]
242
249
  handle_session_action(socket, request[:body], request[:headers])
250
+ when ["POST", "/skill-capture/draft"]
251
+ handle_skill_capture_draft(socket, request[:body], request[:headers])
252
+ when ["POST", "/skill-capture/save"]
253
+ handle_skill_capture_save(socket, request[:body], request[:headers])
243
254
  else
244
255
  write_response(socket, 404, { "Content-Type" => "text/plain; charset=utf-8" }, "Not found\n")
245
256
  end
@@ -308,6 +319,42 @@ module Kward
308
319
  write_json(socket, 400, ok: false, error: "Invalid JSON")
309
320
  end
310
321
 
322
+ def handle_skill_capture_draft(socket, body, headers)
323
+ return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
324
+
325
+ params = JSON.parse(body.empty? ? "{}" : body)
326
+ draft = skill_capture.generate(params.fetch("sessionPath"))
327
+ write_json(socket, 200, ok: true, content: draft.content, sourcePath: draft.source_path, name: draft.name, description: draft.description)
328
+ rescue JSON::ParserError
329
+ write_json(socket, 400, ok: false, error: "Invalid JSON")
330
+ rescue Skills::Capture::Error => error
331
+ write_json(socket, 422, ok: false, error: error.message)
332
+ end
333
+
334
+ def handle_skill_capture_save(socket, body, headers)
335
+ return write_json(socket, 415, ok: false, error: "Content-Type must be application/json") unless json_request?(headers)
336
+
337
+ params = JSON.parse(body.empty? ? "{}" : body)
338
+ draft = skill_capture.save(params.fetch("content"), overwrite: params["overwrite"] == true)
339
+ write_json(socket, 200, ok: true, path: skill_capture.skill_path(draft.name), name: draft.name, description: draft.description)
340
+ rescue JSON::ParserError
341
+ write_json(socket, 400, ok: false, error: "Invalid JSON")
342
+ rescue Skills::Capture::ConflictError => error
343
+ write_json(socket, 409, ok: false, error: error.message)
344
+ rescue Skills::Capture::Error => error
345
+ write_json(socket, 422, ok: false, error: error.message)
346
+ end
347
+
348
+ def skill_capture_sessions
349
+ @session_store.capture_candidates.map do |session|
350
+ { path: session.path, name: session.name, firstMessage: session.first_message, workspaceRoot: session.cwd, modifiedAt: session.modified_at.utc.iso8601(3) }
351
+ end
352
+ end
353
+
354
+ def skill_capture
355
+ @skill_capture ||= Skills::Capture.new(session_store: @session_store, client: @client, config_dir: @session_store.config_dir)
356
+ end
357
+
311
358
  def update_session(params)
312
359
  return { status: 409, ok: false, error: "Wait for queued turns to finish before changing sessions" } if turns_pending?
313
360
 
@@ -0,0 +1,171 @@
1
+ require "uri"
2
+
3
+ # Namespace for Kward's permission-policy runtime.
4
+ module Kward
5
+ module Permissions
6
+ # Evaluates the opt-in permission policy for model-requested tool calls.
7
+ #
8
+ # The policy is intentionally a decision layer, not a process sandbox. It
9
+ # can prevent Kward from starting a tool, but it cannot constrain a shell
10
+ # command after it has begun. OS-enforced sandboxing belongs at a lower
11
+ # process boundary.
12
+ class Policy
13
+ MODES = %w[ask read-only workspace-write deny-by-default].freeze
14
+ NETWORK_TOOLS = %w[web_search fetch_content fetch_raw].freeze
15
+ FILE_CHANGE_TOOLS = %w[write_file edit_file].freeze
16
+
17
+ Decision = Struct.new(:action, :reason, keyword_init: true) do
18
+ def allowed?
19
+ action == :allow
20
+ end
21
+
22
+ def approval_required?
23
+ action == :ask
24
+ end
25
+
26
+ def denied?
27
+ action == :deny
28
+ end
29
+ end
30
+
31
+ def self.from_config(config)
32
+ permissions = config["permissions"].is_a?(Hash) ? config["permissions"] : {}
33
+ new(
34
+ enabled: permissions["enabled"] == true,
35
+ mode: permissions["mode"],
36
+ allow: permissions["allow"],
37
+ ask: permissions["ask"],
38
+ deny: permissions["deny"],
39
+ write_scopes: permissions.key?("write_scopes") ? permissions["write_scopes"] : nil
40
+ )
41
+ end
42
+
43
+ def initialize(enabled: false, mode: "ask", allow: [], ask: [], deny: [], write_scopes: nil)
44
+ @enabled = enabled == true
45
+ @mode = normalize_mode(mode)
46
+ @allow = normalize_rules(allow)
47
+ @ask = normalize_rules(ask)
48
+ @deny = normalize_rules(deny)
49
+ @write_scopes = normalize_scopes(write_scopes)
50
+ @session_allowed_tools = {}
51
+ end
52
+
53
+ def enabled?
54
+ @enabled
55
+ end
56
+
57
+ # Allows this model tool for the remaining lifetime of this policy object.
58
+ # A matching deny or ask rule still takes precedence over this temporary grant.
59
+ def allow_for_session!(tool_name)
60
+ @session_allowed_tools[tool_name.to_s] = true
61
+ end
62
+
63
+ def decision_for(tool_name, arguments, source: nil)
64
+ return Decision.new(action: :allow, reason: "permissions disabled") unless enabled?
65
+
66
+ request = request_for(tool_name, arguments, source: source)
67
+ return Decision.new(action: :deny, reason: "matched deny rule") if matches?(@deny, request)
68
+ return Decision.new(action: :ask, reason: "matched ask rule") if matches?(@ask, request)
69
+ return Decision.new(action: :allow, reason: "matched allow rule") if matches?(@allow, request)
70
+ return Decision.new(action: :allow, reason: "allowed for this session") if @session_allowed_tools[request.fetch("tool")]
71
+
72
+ default_decision(request)
73
+ end
74
+
75
+ private
76
+
77
+ def normalize_mode(mode)
78
+ value = mode.to_s
79
+ MODES.include?(value) ? value : "ask"
80
+ end
81
+
82
+ def normalize_rules(rules)
83
+ Array(rules).filter_map do |rule|
84
+ next unless rule.is_a?(Hash)
85
+
86
+ rule.transform_keys(&:to_s).slice("tool", "path", "host", "command", "source")
87
+ end
88
+ end
89
+
90
+ def normalize_scopes(scopes)
91
+ return nil if scopes.nil?
92
+
93
+ Array(scopes).map(&:to_s).reject(&:empty?)
94
+ end
95
+
96
+ def request_for(tool_name, arguments, source:)
97
+ name = tool_name.to_s
98
+ args = arguments.to_h
99
+ {
100
+ "tool" => name,
101
+ "path" => args["path"] || args[:path],
102
+ "host" => request_host(name, args),
103
+ "command" => args["command"] || args[:command],
104
+ "source" => source.to_s
105
+ }.compact.transform_values(&:to_s)
106
+ end
107
+
108
+ def request_host(name, arguments)
109
+ return nil unless %w[fetch_content fetch_raw].include?(name)
110
+
111
+ URI.parse((arguments["url"] || arguments[:url]).to_s).host
112
+ rescue URI::InvalidURIError
113
+ nil
114
+ end
115
+
116
+ def matches?(rules, request)
117
+ rules.any? { |rule| rule_matches?(rule, request) }
118
+ end
119
+
120
+ def rule_matches?(rule, request)
121
+ return false if rule.empty?
122
+
123
+ rule.all? do |field, pattern|
124
+ value = request[field]
125
+ value && glob_match?(pattern.to_s, value)
126
+ end
127
+ end
128
+
129
+ def glob_match?(pattern, value)
130
+ expression = Regexp.escape(pattern).gsub("\\*\\*", ".*").gsub("\\*", "[^/]*")
131
+ Regexp.new("\\A#{expression}\\z").match?(value)
132
+ end
133
+
134
+ def default_decision(request)
135
+ return Decision.new(action: :deny, reason: "read-only mode") if @mode == "read-only" && risky?(request)
136
+ return Decision.new(action: :deny, reason: "deny-by-default mode") if @mode == "deny-by-default" && risky?(request)
137
+
138
+ if file_change?(request) && outside_write_scopes?(request)
139
+ return Decision.new(action: :deny, reason: "path outside write scopes")
140
+ end
141
+
142
+ if @mode == "workspace-write" && file_change?(request)
143
+ return Decision.new(action: :allow, reason: "workspace write mode")
144
+ end
145
+
146
+ return Decision.new(action: :allow, reason: "read-only tool") unless risky?(request)
147
+
148
+ Decision.new(action: :ask, reason: "#{request.fetch("tool")} requires approval")
149
+ end
150
+
151
+ def file_change?(request)
152
+ FILE_CHANGE_TOOLS.include?(request.fetch("tool"))
153
+ end
154
+
155
+ def network?(request)
156
+ NETWORK_TOOLS.include?(request.fetch("tool")) || request["source"] == "mcp"
157
+ end
158
+
159
+ def risky?(request)
160
+ file_change?(request) || request.fetch("tool") == "run_shell_command" || network?(request)
161
+ end
162
+
163
+ def outside_write_scopes?(request)
164
+ return false if @write_scopes.nil?
165
+
166
+ path = request["path"]
167
+ path.nil? || !@write_scopes.any? { |scope| glob_match?(scope, path) }
168
+ end
169
+ end
170
+ end
171
+ end
@@ -34,6 +34,10 @@ module Kward
34
34
  end
35
35
  end
36
36
 
37
+ # Registered plugin-owned tab runtime. Its factory receives a
38
+ # `PluginTabHost` and its persisted descriptor, then returns a driver.
39
+ TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transcript_events, :path, :handler, keyword_init: true)
40
+
37
41
  # Read-only event passed to plugin transcript observers.
38
42
  TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
39
43
  def to_h
@@ -264,6 +268,23 @@ module Kward
264
268
  def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
265
269
  @registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
266
270
  end
271
+
272
+ # Registers a plugin-owned tab type for the interactive CLI. `id` is a
273
+ # durable identifier used in persisted tab layouts and must not change.
274
+ # The factory receives a `PluginTabHost` and a descriptor hash.
275
+ #
276
+ # @param name [String] command name used by `/tab open <name>`
277
+ # @param id [String] stable persisted tab type identifier
278
+ # @param title [String] default tab label
279
+ # @param singleton [Symbol] `:global` for one shared plugin runtime
280
+ # @param transcript_events [Boolean] allow global transcript observers to receive this tab's events
281
+ # @yieldparam host [PluginTabHost] supported host dependencies
282
+ # @yieldparam descriptor [Hash] persisted tab descriptor
283
+ # @return [void]
284
+ # @api public
285
+ def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, &block)
286
+ @registry.register_tab_type(name, id: id, title: title, singleton: singleton, rpc: rpc, transcript_events: transcript_events, path: @path, &block)
287
+ end
267
288
  end
268
289
 
269
290
  # Mutable singleton guard used while loading trusted plugin files.
@@ -282,6 +303,8 @@ module Kward
282
303
  @reserved_commands = reserved_commands.map(&:to_s)
283
304
  @commands = {}
284
305
  @interactive_commands = {}
306
+ @tab_types = {}
307
+ @tab_types_by_id = {}
285
308
  @footer = nil
286
309
  @footer_path = nil
287
310
  @transcript_event_handlers = []
@@ -312,6 +335,18 @@ module Kward
312
335
  @interactive_commands[name.to_s]
313
336
  end
314
337
 
338
+ def tab_types
339
+ @tab_types.values
340
+ end
341
+
342
+ def tab_type_for(name)
343
+ @tab_types[name.to_s]
344
+ end
345
+
346
+ def tab_type_for_id(id)
347
+ @tab_types_by_id[id.to_s]
348
+ end
349
+
315
350
  def footer_renderer
316
351
  @footer
317
352
  end
@@ -366,7 +401,7 @@ module Kward
366
401
  previous_path = self.class.loading_path
367
402
  self.class.loading_registry = self
368
403
  self.class.loading_path = path
369
- Kernel.load(path)
404
+ Kernel.load(path, true)
370
405
  @paths << path
371
406
  rescue StandardError => e
372
407
  warn "Warning: skipping Kward plugin #{path}: #{e.message}"
@@ -429,6 +464,23 @@ module Kward
429
464
  )
430
465
  end
431
466
 
467
+ def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, path: nil, &handler)
468
+ name = name.to_s
469
+ id = id.to_s
470
+ raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
471
+ raise "Plugin tab type id is required" if id.empty?
472
+ raise "Plugin tab type #{name} requires a handler" unless handler
473
+
474
+ if @tab_types.key?(name) || @tab_types_by_id.key?(id)
475
+ warn "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
476
+ return nil
477
+ end
478
+
479
+ tab_type = TabType.new(id: id, name: name, title: title.to_s.empty? ? name.capitalize : title.to_s, singleton: singleton&.to_sym, rpc: rpc == true, transcript_events: transcript_events == true, path: path, handler: handler)
480
+ @tab_types[name] = tab_type
481
+ @tab_types_by_id[id] = tab_type
482
+ end
483
+
432
484
  def register_footer(path: nil, &renderer)
433
485
  raise "Plugin footer requires a renderer" unless renderer
434
486
 
@@ -500,7 +552,7 @@ module Kward
500
552
  end
501
553
  end
502
554
 
503
- def transcript_event(type, payload)
555
+ def transcript_event(type, payload = {})
504
556
  TranscriptEvent.new(
505
557
  type: type,
506
558
  payload: DeepCopy.freeze(DeepCopy.dup(payload))
@@ -0,0 +1,62 @@
1
+ require "json"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ class PromptInterface
6
+ # Tool-approval overlay built on the structured question modal.
7
+ module ApprovalPrompt
8
+ # Asks the captain to approve a model-requested tool call. Cancellation is
9
+ # intentionally a denial so a closed or interrupted overlay never permits
10
+ # a side effect.
11
+ def ask_tool_approval(tool_name:, args:, reason: nil)
12
+ summary, details = tool_approval_details(tool_name, args)
13
+ question = (["The agent wants to #{summary}."] + Array(details) + [reason.to_s].reject(&:empty?)).join("\n")
14
+ answers = ask_user_question([
15
+ {
16
+ header: "Approval required · #{tool_approval_title(tool_name)}",
17
+ question: question,
18
+ options: [
19
+ { label: "Allow once", description: "Run this tool call." },
20
+ { label: "Allow this tool for this session", description: "Run this call and future calls to #{tool_name}." },
21
+ { label: "Deny", description: "Do not run this tool call." }
22
+ ]
23
+ }
24
+ ])
25
+ answer = answers&.first
26
+ return { denied_message: answer[:answer] } if answer&.fetch(:custom, false) && !answer[:answer].to_s.empty?
27
+
28
+ case answer&.fetch(:answer, nil)
29
+ when "Allow once" then true
30
+ when "Allow this tool for this session" then :allow_for_session
31
+ else false
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def tool_approval_title(tool_name)
38
+ case tool_name.to_s
39
+ when "run_shell_command" then "Shell command"
40
+ when "write_file" then "Write file"
41
+ when "edit_file" then "Edit file"
42
+ when "fetch_content", "fetch_raw" then "Network request"
43
+ when "web_search" then "Web search"
44
+ else tool_name.to_s.tr("_", " ").capitalize
45
+ end
46
+ end
47
+
48
+ def tool_approval_details(tool_name, args)
49
+ action = case tool_name.to_s
50
+ when "run_shell_command" then "run this shell command"
51
+ when "write_file" then "write this file"
52
+ when "edit_file" then "edit this file"
53
+ when "read_file", "read_skill" then "read these resources"
54
+ when "fetch_content", "fetch_raw" then "make this network request"
55
+ when "web_search" then "search the web"
56
+ else "use #{tool_name}"
57
+ end
58
+ [action, ["Arguments:\n#{JSON.pretty_generate(args.to_h)}"]]
59
+ end
60
+ end
61
+ end
62
+ end
@@ -153,11 +153,11 @@ module Kward
153
153
  pending_deletions << line[1..].to_s
154
154
  elsif line.start_with?("+")
155
155
  deleted = pending_deletions.shift
156
- rows << side_by_side_diff_row(deleted && "- #{deleted}", "+ #{line[1..]}", width)
156
+ rows.concat(side_by_side_diff_rows(deleted && "- #{deleted}", "+ #{line[1..]}", width))
157
157
  else
158
158
  rows.concat(flush_side_by_side_deletions(pending_deletions, width))
159
159
  text = line.start_with?(" ") ? line[1..].to_s : line
160
- rows << side_by_side_diff_row(" #{text}", " #{text}", width)
160
+ rows.concat(side_by_side_diff_rows(" #{text}", " #{text}", width))
161
161
  end
162
162
  end
163
163
 
@@ -170,7 +170,17 @@ module Kward
170
170
  end
171
171
 
172
172
  def flush_side_by_side_deletions(lines, width)
173
- lines.shift(lines.length).map { |line| side_by_side_diff_row("- #{line}", nil, width) }
173
+ lines.shift(lines.length).flat_map { |line| side_by_side_diff_rows("- #{line}", nil, width) }
174
+ end
175
+
176
+ def side_by_side_diff_rows(left, right, width)
177
+ return [side_by_side_diff_row(left, right, width)] unless current_editor_soft_wrap?
178
+
179
+ left_lines = side_by_side_diff_cell_lines(left, width)
180
+ right_lines = side_by_side_diff_cell_lines(right, width)
181
+ [left_lines.length, right_lines.length].max.times.map do |index|
182
+ side_by_side_diff_row(left_lines[index], right_lines[index], width)
183
+ end
174
184
  end
175
185
 
176
186
  def side_by_side_diff_row(left, right, width)
@@ -184,6 +194,17 @@ module Kward
184
194
  visible_truncate(value, width)
185
195
  end
186
196
 
197
+ def side_by_side_diff_cell_lines(text, width)
198
+ return [""] if text.nil?
199
+
200
+ value = text.to_s
201
+ prefix = value.start_with?("- ", "+ ", " ") ? value[0, 2] : ""
202
+ content = value.delete_prefix(prefix)
203
+ return [prefix] if content.empty?
204
+
205
+ content.chars.each_slice([width - prefix.length, 1].max).map { |characters| "#{prefix}#{characters.join}" }
206
+ end
207
+
187
208
  def side_by_side_diff_column_width
188
209
  content_width = [screen_width - 4, 1].max
189
210
  gutter_width = [[999.to_s.length, 4].max + 3, 1].max
@@ -220,6 +241,7 @@ module Kward
220
241
  @editor_text_width = nil
221
242
  @editor_save_as_active = false
222
243
  @editor_save_as_buffer = ""
244
+ @editor_save_callback = nil
223
245
  @editor_state = nil
224
246
  @prompt_label = "You>"
225
247
  self.composer_input = ""
@@ -1150,6 +1172,17 @@ module Kward
1150
1172
  return true
1151
1173
  end
1152
1174
 
1175
+ if @editor_save_callback
1176
+ result = @editor_save_callback.call(@editor_state.buffer)
1177
+ if result.to_s.empty?
1178
+ close_editor
1179
+ return true
1180
+ end
1181
+
1182
+ @editor_state.status = result.to_s
1183
+ return false
1184
+ end
1185
+
1153
1186
  if path.to_s.strip.empty? && @editor_state.path.to_s.empty?
1154
1187
  if prompt_for_path
1155
1188
  begin_editor_save_as
@@ -337,11 +337,12 @@ module Kward
337
337
 
338
338
  def question_overlay_rows(width)
339
339
  title = "Question #{@question_state[:index]}/#{@question_state[:total]} · #{@question_state[:header]}"
340
- lines = [
341
- overlay_text_line(@question_state[:question].to_s, :bold),
340
+ content_width = [overlay_card_width(width) - 4, 1].max
341
+ lines = question_text_rows(@question_state[:question], content_width, :bold)
342
+ lines.concat([
342
343
  overlay_text_line("↑/↓ select · Enter choose · Esc cancel", :muted),
343
344
  overlay_blank_line
344
- ]
345
+ ])
345
346
  question_choices.each_with_index do |choice, index|
346
347
  selected = index == question_selection_index
347
348
  lines << overlay_choice_line(choice_text(choice, selected: selected), selected: selected)
@@ -349,6 +350,14 @@ module Kward
349
350
  overlay_card_rows(title, lines, width)
350
351
  end
351
352
 
353
+ def question_text_rows(text, width, style)
354
+ text.to_s.split("\n", -1).flat_map do |line|
355
+ wrapped = ANSI.wrap_visible(line, width)
356
+ wrapped = [""] if wrapped.empty?
357
+ wrapped.map { |row| overlay_text_line(row, style) }
358
+ end
359
+ end
360
+
352
361
  def choice_text(choice, selected: false)
353
362
  if choice[:custom]
354
363
  selected ? "Type a custom answer below." : "Type something."
@@ -25,6 +25,7 @@ require_relative "prompt_interface/file_overlay"
25
25
  require_relative "prompt_interface/project_browser"
26
26
  require_relative "prompt_interface/selection_prompt"
27
27
  require_relative "prompt_interface/question_prompt"
28
+ require_relative "prompt_interface/approval_prompt"
28
29
  require_relative "prompt_interface/git_prompt"
29
30
  require_relative "prompt_interface/overlay_renderer"
30
31
  require_relative "prompt_interface/editor/renderer"
@@ -75,6 +76,7 @@ module Kward
75
76
  include ProjectBrowser
76
77
  include SelectionPrompt
77
78
  include QuestionPrompt
79
+ include ApprovalPrompt
78
80
  include GitPrompt
79
81
  include OverlayRenderer
80
82
  include EditorRenderer
@@ -362,6 +364,24 @@ module Kward
362
364
  run_editor
363
365
  end
364
366
 
367
+ # Opens an in-memory document editor. The callback receives edited content
368
+ # when the user saves; return an error message to keep the editor open.
369
+ def review_document(title:, content:, &on_save)
370
+ raise ArgumentError, "review_document requires a save callback" unless on_save
371
+
372
+ start(render: false)
373
+ opened = @mutex.synchronize do
374
+ @editor_save_callback = on_save
375
+ open_scratchpad(:markdown, content: content).tap do
376
+ @editor_state.status = "#{title} · Ctrl+S save · Ctrl+Q cancel"
377
+ render_prompt_locked
378
+ end
379
+ end
380
+ return false unless opened
381
+
382
+ run_editor
383
+ end
384
+
365
385
  def run_editor
366
386
  loop do
367
387
  key = read_key(nonblock: true)
@@ -33,7 +33,9 @@ module Kward
33
33
  { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name]" },
34
34
  { name: "status", description: "Show the current status message.", argument_hint: "" },
35
35
  { name: "stats", description: "Show telemetry logging stats.", argument_hint: "[range]" },
36
+ { name: "sandbox", description: "Inspect or configure model command sandboxing.", argument_hint: "[status|off|read_only|workspace_write|network allow|network deny]" },
36
37
  { name: "hooks", description: "Inspect lifecycle hooks.", argument_hint: "[list|events|logs|doctor|trust|untrust]" },
38
+ { name: "skill", description: "Activate a skill or capture one from a saved session.", argument_hint: "<name>|capture" },
37
39
  { name: "memory", description: "Inspect and manage Kward memory.", argument_hint: "[enable|disable|auto-summary|core|add|list|forget|promote|relax|inspect|why|summarize]" }
38
40
  ].freeze
39
41
  BUILTIN_RESERVED_COMMAND_NAMES = BUILTIN_COMMANDS.map { |command| command[:name] }.freeze
data/lib/kward/prompts.rb CHANGED
@@ -38,6 +38,8 @@ module Kward
38
38
  # @return [Array<Hash>] section hashes with label, content, and optional source
39
39
  # @api public
40
40
  def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
41
+ return replacement_prompt_sections if ConfigFiles.replacement_system_prompt?
42
+
41
43
  sections = [prompt_section("Built-in system prompt", base_prompt)]
42
44
  sections << prompt_section(config_agents_prompt_label, config_agents_prompt, source: config_agents_prompt_source)
43
45
  sections << prompt_section("Memory context", memory_context) unless memory_context.to_s.empty?
@@ -53,11 +55,11 @@ module Kward
53
55
 
54
56
  def base_prompt
55
57
  <<~PROMPT.strip
56
- You are Kward, a concise practical CLI coding agent. Use tools to understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
58
+ You are Kward, a concise practical CLI coding agent. Use tools to understand and modify software projects. Only call tools advertised for the current turn. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
57
59
 
58
- For web research, use web_search to discover sources, fetch_content for important human-readable pages, and fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources and cite or mention the URLs you relied on.
60
+ When web tools are available, use web_search to discover sources, fetch_content for important human-readable pages, and fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources and cite or mention the URLs you relied on. Use code_search for package, GitHub repository, and source-code research.
59
61
 
60
- Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. Use context_budget_stats when asked about context savings.
62
+ Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. If output is compacted or replaced with a duplicate reference, use retrieve_tool_output to inspect the needed original detail. Use context_budget_stats when asked about context savings. When a material requirement is ambiguous, use ask_user_question when it is available; otherwise state the assumption.
61
63
  PROMPT
62
64
  end
63
65
 
@@ -73,6 +75,7 @@ module Kward
73
75
  end
74
76
 
75
77
  def config_agents_prompt_source
78
+ return nil unless ConfigFiles.include_config_principles?
76
79
  return ConfigFiles.config_principles_path if File.exist?(ConfigFiles.config_principles_path)
77
80
  return ConfigFiles.config_agents_path if File.exist?(ConfigFiles.config_agents_path)
78
81
 
@@ -112,6 +115,14 @@ module Kward
112
115
  "Workspace AGENTS.md hint"
113
116
  end
114
117
 
118
+ def replacement_prompt_sections
119
+ path = ConfigFiles.system_prompt_file_path
120
+ content = ConfigFiles.system_prompt_file
121
+ return [] if content.to_s.empty?
122
+
123
+ [prompt_section("Custom system prompt (replacement)", content, source: path)]
124
+ end
125
+
115
126
  def prompt_section(label, content, source: nil)
116
127
  return nil if content.to_s.empty?
117
128
 
@@ -123,8 +134,8 @@ module Kward
123
134
 
124
135
  lines = [
125
136
  "Agent Skills are available.",
126
- "When a task matches a skill's description, use read_skill to load its instructions before proceeding.",
127
- "Use read_skill with a relative path to read referenced files inside a skill directory.",
137
+ "When read_skill is available and a task matches a skill's description, use it to load the instructions before proceeding.",
138
+ "When using read_skill, use a relative path to read referenced files inside a skill directory.",
128
139
  "Available skills:"
129
140
  ]
130
141
  skills.each do |skill|