kward 0.77.0 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -0
  5. data/doc/code-search.md +9 -6
  6. data/doc/configuration.md +39 -0
  7. data/doc/permissions.md +179 -0
  8. data/doc/plugins.md +58 -0
  9. data/doc/rpc.md +61 -0
  10. data/doc/security.md +4 -0
  11. data/doc/tabs.md +4 -3
  12. data/doc/web-search.md +4 -2
  13. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  14. data/lib/kward/auth/github_oauth.rb +3 -3
  15. data/lib/kward/auth/oauth_helpers.rb +4 -2
  16. data/lib/kward/auth/openai_oauth.rb +2 -1
  17. data/lib/kward/cli/plugins.rb +22 -0
  18. data/lib/kward/cli/rendering.rb +7 -1
  19. data/lib/kward/cli/runtime_helpers.rb +14 -0
  20. data/lib/kward/cli/tabs.rb +140 -34
  21. data/lib/kward/cli.rb +29 -9
  22. data/lib/kward/config_files.rb +10 -0
  23. data/lib/kward/hooks/http_handler.rb +2 -1
  24. data/lib/kward/http.rb +18 -0
  25. data/lib/kward/model/client.rb +33 -11
  26. data/lib/kward/model/payloads.rb +6 -1
  27. data/lib/kward/openrouter_model_cache.rb +2 -1
  28. data/lib/kward/permissions/policy.rb +171 -0
  29. data/lib/kward/plugin_registry.rb +53 -1
  30. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  31. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  32. data/lib/kward/prompt_interface.rb +2 -0
  33. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  34. data/lib/kward/rpc/server.rb +35 -0
  35. data/lib/kward/rpc/session_manager.rb +1 -0
  36. data/lib/kward/rpc/transcript_normalizer.rb +14 -5
  37. data/lib/kward/starter_pack_installer.rb +3 -1
  38. data/lib/kward/tab_driver.rb +87 -0
  39. data/lib/kward/tab_store.rb +74 -12
  40. data/lib/kward/tools/code_search.rb +8 -2
  41. data/lib/kward/tools/fetch_content.rb +4 -2
  42. data/lib/kward/tools/fetch_raw.rb +3 -1
  43. data/lib/kward/tools/registry.rb +37 -4
  44. data/lib/kward/tools/search/code.rb +46 -12
  45. data/lib/kward/tools/search/web.rb +60 -17
  46. data/lib/kward/tools/search/web_fetch.rb +206 -38
  47. data/lib/kward/tools/web_search.rb +3 -1
  48. data/lib/kward/update_check.rb +2 -1
  49. data/lib/kward/version.rb +1 -1
  50. data/templates/default/kward_navigation.rb +2 -1
  51. data/templates/default/layout/html/layout.erb +2 -0
  52. data/templates/default/layout/html/setup.rb +1 -0
  53. metadata +7 -1
@@ -6,6 +6,7 @@ require_relative "../auth/github_oauth"
6
6
  require_relative "../auth/openai_oauth"
7
7
  require_relative "../cancellation"
8
8
  require_relative "../config_files"
9
+ require_relative "../http"
9
10
  require_relative "../openrouter_model_cache"
10
11
  require_relative "context_overflow"
11
12
  require_relative "copilot_models"
@@ -35,6 +36,7 @@ module Kward
35
36
  DEFAULT_OPENAI_MODEL = ModelInfo::DEFAULT_OPENAI_MODEL
36
37
  DEFAULT_REASONING_EFFORT = ModelInfo::DEFAULT_REASONING_EFFORT
37
38
  RETRY_DELAYS = [1, 2].freeze
39
+ DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS = 120
38
40
  NON_RETRYABLE_PROVIDER_LIMIT_PATTERNS = [
39
41
  /GoUsageLimitError/i,
40
42
  /FreeUsageLimitError/i,
@@ -335,7 +337,7 @@ module Kward
335
337
  end
336
338
 
337
339
  def chat_anthropic_provider(url:, token:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:)
338
- request = Net::HTTP::Post.new(url)
340
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
339
341
  request["Authorization"] = "Bearer #{token}"
340
342
  request["Content-Type"] = "application/json"
341
343
  request["Accept"] = "text/event-stream"
@@ -343,7 +345,7 @@ module Kward
343
345
  request.body = request_body
344
346
 
345
347
  message = nil
346
- Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: nil) do |http|
348
+ Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
347
349
  cancellation&.on_cancel { close_http(http) }
348
350
  cancellation&.raise_if_cancelled!
349
351
  http.request(request) do |response|
@@ -361,7 +363,7 @@ module Kward
361
363
  end
362
364
 
363
365
  def chat_openrouter_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
364
- request = Net::HTTP::Post.new(url)
366
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
365
367
  request["Authorization"] = "Bearer #{token}"
366
368
  request["Content-Type"] = "application/json"
367
369
  request.body = request_body
@@ -547,7 +549,7 @@ module Kward
547
549
  return [] if token.empty?
548
550
 
549
551
  url = URI("#{@github_oauth.base_url}/models")
550
- request = Net::HTTP::Get.new(url)
552
+ request = Http.apply_user_agent(Net::HTTP::Get.new(url))
551
553
  request["Authorization"] = "Bearer #{token}"
552
554
  request["Accept"] = "application/json"
553
555
  copilot_headers([]).each { |key, value| request[key] = value }
@@ -577,7 +579,7 @@ module Kward
577
579
 
578
580
  def copilot_responses_chat(token, request_body:, on_assistant_delta: nil, cancellation: nil)
579
581
  url = URI("#{@github_oauth.base_url}/responses")
580
- request = Net::HTTP::Post.new(url)
582
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
581
583
  request["Authorization"] = "Bearer #{token}"
582
584
  request["Content-Type"] = "application/json"
583
585
  request["Accept"] = "text/event-stream"
@@ -585,7 +587,7 @@ module Kward
585
587
  request.body = request_body
586
588
 
587
589
  message = nil
588
- Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: nil) do |http|
590
+ Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
589
591
  cancellation&.on_cancel { close_http(http) }
590
592
  cancellation&.raise_if_cancelled!
591
593
  http.request(request) do |response|
@@ -603,7 +605,7 @@ module Kward
603
605
  end
604
606
 
605
607
  def copilot_chat(url, token, messages, tools, request_body: nil, on_assistant_delta: nil, cancellation: nil)
606
- request = Net::HTTP::Post.new(url)
608
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
607
609
  request["Authorization"] = "Bearer #{token}"
608
610
  request["Content-Type"] = "application/json"
609
611
  request["Accept"] = "text/event-stream"
@@ -633,7 +635,6 @@ module Kward
633
635
  "anthropic-version" => "2023-06-01",
634
636
  "anthropic-beta" => "claude-code-20250219,oauth-2025-04-20",
635
637
  "anthropic-dangerous-direct-browser-access" => "true",
636
- "user-agent" => "claude-cli/2.1.75",
637
638
  "x-app" => "cli"
638
639
  }
639
640
  end
@@ -653,16 +654,16 @@ module Kward
653
654
  end
654
655
 
655
656
  def codex_chat(url, token, account_id, messages, tools, request_body: nil, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, max_tokens: nil)
656
- request = Net::HTTP::Post.new(url)
657
+ request = Http.apply_user_agent(Net::HTTP::Post.new(url))
657
658
  request["Authorization"] = "Bearer #{token}"
658
659
  request["ChatGPT-Account-Id"] = account_id if account_id
659
660
  request["Content-Type"] = "application/json"
660
661
  request["Accept"] = "text/event-stream"
661
- request["originator"] = "codex_cli_rs"
662
662
  request.body = request_body || JSON.dump(codex_payload(messages, tools, max_tokens: max_tokens))
663
+ apply_codex_identity(request, luna: luna_request?(request.body))
663
664
 
664
665
  message = nil
665
- Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: nil) do |http|
666
+ Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
666
667
  cancellation&.on_cancel { close_http(http) }
667
668
  cancellation&.raise_if_cancelled!
668
669
  http.request(request) do |response|
@@ -684,6 +685,22 @@ module Kward
684
685
  raise e
685
686
  end
686
687
 
688
+ def apply_codex_identity(request, luna:)
689
+ request["originator"] = "kward"
690
+ return unless luna
691
+
692
+ # TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
693
+ request["originator"] = "codex_cli_rs"
694
+ request["User-Agent"] = "codex_cli_rs/0.144.1"
695
+ request["x-openai-internal-codex-responses-lite"] = "true"
696
+ end
697
+
698
+ def luna_request?(request_body)
699
+ JSON.parse(request_body)["model"] == "gpt-5.6-luna"
700
+ rescue JSON::ParserError
701
+ false
702
+ end
703
+
687
704
  def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil)
688
705
  ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
689
706
  end
@@ -842,6 +859,11 @@ module Kward
842
859
  @config["codex_show_raw_reasoning"] == true
843
860
  end
844
861
 
862
+ def stream_idle_timeout_seconds
863
+ value = @config["stream_idle_timeout_seconds"].to_i
864
+ value.positive? ? value : DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS
865
+ end
866
+
845
867
  def load_config
846
868
  ConfigFiles.read_config(@config_path)
847
869
  end
@@ -126,7 +126,12 @@ module Kward
126
126
  store: false,
127
127
  include: []
128
128
  }
129
- payload[:reasoning] = { effort: reasoning || reasoning_effort("Codex"), summary: "auto" } unless reasoning == false
129
+ unless reasoning == false
130
+ effort = reasoning || reasoning_effort("Codex")
131
+ payload[:reasoning] = { effort: effort, summary: "auto" }
132
+ # TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
133
+ payload[:reasoning][:context] = "all_turns" if parts[:model] == "gpt-5.6-luna"
134
+ end
130
135
  payload
131
136
  end
132
137
 
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require "time"
5
5
  require "uri"
6
6
  require_relative "config_files"
7
+ require_relative "http"
7
8
  require_relative "private_file"
8
9
 
9
10
  # Namespace for the Kward CLI agent runtime.
@@ -71,7 +72,7 @@ module Kward
71
72
  private
72
73
 
73
74
  def refresh_request
74
- Net::HTTP::Get.new(MODELS_URL).tap do |request|
75
+ Http.apply_user_agent(Net::HTTP::Get.new(MODELS_URL)).tap do |request|
75
76
  request["Authorization"] = "Bearer #{@api_key}"
76
77
  request["Accept"] = "application/json"
77
78
  end
@@ -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
@@ -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
@@ -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