kward 0.79.0 → 0.80.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +65 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/skills.md +2 -0
  32. data/doc/tabs.md +37 -0
  33. data/doc/telegram.md +138 -0
  34. data/doc/transports.md +209 -0
  35. data/doc/usage.md +11 -3
  36. data/doc/web-search.md +3 -3
  37. data/doc/workspace-tools.md +2 -2
  38. data/examples/plugins/stardate_footer.rb +10 -0
  39. data/examples/plugins/telegram/plugin.rb +39 -0
  40. data/examples/plugins/telegram/telegram_api.rb +136 -0
  41. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  42. data/lib/kward/agent.rb +11 -6
  43. data/lib/kward/ansi.rb +1 -0
  44. data/lib/kward/auth/api_key_store.rb +99 -0
  45. data/lib/kward/cli/auth_commands.rb +72 -13
  46. data/lib/kward/cli/commands.rb +7 -0
  47. data/lib/kward/cli/git.rb +31 -8
  48. data/lib/kward/cli/memory_commands.rb +2 -2
  49. data/lib/kward/cli/plugins.rb +1 -0
  50. data/lib/kward/cli/prompt_interface.rb +44 -1
  51. data/lib/kward/cli/rendering.rb +6 -2
  52. data/lib/kward/cli/runtime_helpers.rb +5 -2
  53. data/lib/kward/cli/sessions.rb +1 -1
  54. data/lib/kward/cli/settings.rb +97 -33
  55. data/lib/kward/cli/slash_commands.rb +1 -1
  56. data/lib/kward/cli/tabs.rb +103 -37
  57. data/lib/kward/cli/transports.rb +67 -0
  58. data/lib/kward/cli/worktrees.rb +360 -0
  59. data/lib/kward/cli.rb +22 -0
  60. data/lib/kward/compactor.rb +7 -2
  61. data/lib/kward/config_files.rb +51 -12
  62. data/lib/kward/conversation.rb +13 -7
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/hooks/audit_log.rb +5 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +225 -20
  68. data/lib/kward/model/model_info.rb +49 -0
  69. data/lib/kward/model/payloads.rb +63 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +83 -13
  73. data/lib/kward/permissions/policy.rb +18 -4
  74. data/lib/kward/plugin_chat_runtime.rb +374 -0
  75. data/lib/kward/plugin_registry.rb +99 -19
  76. data/lib/kward/private_file.rb +9 -3
  77. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  78. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  79. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  80. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  81. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  82. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  83. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  84. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  85. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
  86. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  87. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  88. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  89. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  90. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  92. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  93. data/lib/kward/prompt_interface/screen.rb +3 -0
  94. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  95. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  96. data/lib/kward/prompt_interface.rb +53 -12
  97. data/lib/kward/prompts/commands.rb +1 -1
  98. data/lib/kward/prompts/templates.rb +11 -6
  99. data/lib/kward/prompts.rb +6 -5
  100. data/lib/kward/rpc/auth_manager.rb +112 -152
  101. data/lib/kward/rpc/config_manager.rb +46 -6
  102. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  103. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  104. data/lib/kward/rpc/redactor.rb +1 -1
  105. data/lib/kward/rpc/server.rb +60 -10
  106. data/lib/kward/rpc/session_manager.rb +109 -30
  107. data/lib/kward/session_store.rb +84 -24
  108. data/lib/kward/skills/registry.rb +16 -11
  109. data/lib/kward/tab_driver.rb +6 -3
  110. data/lib/kward/telemetry/logger.rb +5 -2
  111. data/lib/kward/tools/git_commit.rb +39 -0
  112. data/lib/kward/tools/registry.rb +7 -2
  113. data/lib/kward/tools/tool_call.rb +1 -0
  114. data/lib/kward/transport/gateway.rb +253 -0
  115. data/lib/kward/transport/host.rb +343 -0
  116. data/lib/kward/transport/manager.rb +179 -0
  117. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  118. data/lib/kward/transport/runtime.rb +41 -0
  119. data/lib/kward/transport/store.rb +101 -0
  120. data/lib/kward/transport.rb +204 -0
  121. data/lib/kward/version.rb +1 -1
  122. data/lib/kward/workspace_factory.rb +19 -2
  123. data/templates/default/fulldoc/html/js/kward.js +2 -0
  124. data/templates/default/kward_navigation.rb +7 -2
  125. data/templates/default/layout/html/setup.rb +4 -0
  126. metadata +26 -1
@@ -22,12 +22,15 @@ module Kward
22
22
 
23
23
  # Writes the auth status output for the terminal CLI flow.
24
24
  def print_auth_status
25
- config = safely_read_config.to_h
25
+ store = api_key_store
26
+ store.migrate_openrouter_config_key!
26
27
  lines = ["#{colored("Auth Status", :green, :bold)}", ""]
27
28
  lines << auth_status_line("OpenAI OAuth", File.exist?(OpenAIOAuth.default_auth_path), OpenAIOAuth.default_auth_path)
28
29
  lines << auth_status_line("Anthropic OAuth", File.exist?(AnthropicOAuth.default_auth_path), AnthropicOAuth.default_auth_path)
29
30
  lines << auth_status_line("GitHub OAuth", File.exist?(GithubOAuth.default_auth_path), GithubOAuth.default_auth_path)
30
- lines << auth_status_line("OpenRouter API key", !config["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?, ConfigFiles.config_path)
31
+ ProviderCatalog.api_key_providers.each do |provider|
32
+ lines << auth_status_line("#{provider.name} API key", store.configured?(provider.id), store.path)
33
+ end
31
34
  @prompt.say lines.join("\n")
32
35
  end
33
36
 
@@ -45,7 +48,11 @@ module Kward
45
48
  File.delete(path)
46
49
  removed << path
47
50
  end
48
- removed << "OpenRouter API key" if OpenRouterAPIKey.new.logout
51
+ store = api_key_store
52
+ store.migrate_openrouter_config_key!
53
+ ProviderCatalog.api_key_providers.each do |provider|
54
+ removed << "#{provider.name} API key" if store.delete(provider.id)
55
+ end
49
56
 
50
57
  if removed.empty?
51
58
  @prompt.say "No saved credentials found."
@@ -54,29 +61,81 @@ module Kward
54
61
  end
55
62
  end
56
63
 
57
- def login(provider: nil, oauth: nil)
58
- provider = provider.to_s.downcase
59
- if provider == "openrouter"
60
- auth = oauth || OpenRouterAPIKey.new
61
- path = auth.login(prompt: @prompt)
62
- @prompt.say("#{colored("Saved", :green, :bold)} OpenRouter API key to #{path}")
64
+ def login(provider: nil, oauth: nil, auth_method: nil)
65
+ provider = normalized_login_provider(provider)
66
+ if api_key_login?(provider, auth_method)
67
+ login_with_api_key(provider)
63
68
  return
64
69
  end
65
70
 
66
71
  oauth ||= case provider
67
- when "github" then GithubOAuth.new
68
- when "anthropic", "claude" then AnthropicOAuth.new
72
+ when "github", "copilot" then GithubOAuth.new
73
+ when "anthropic" then AnthropicOAuth.new
69
74
  else OpenAIOAuth.new
70
75
  end
71
76
  path = oauth.login(prompt: @prompt)
72
77
  name = case provider
73
- when "github" then "GitHub"
74
- when "anthropic", "claude" then "Anthropic"
78
+ when "github", "copilot" then "GitHub"
79
+ when "anthropic" then "Anthropic"
75
80
  else "OpenAI"
76
81
  end
77
82
  @prompt.say("#{colored("Saved", :green, :bold)} #{name} OAuth login to #{path}")
78
83
  end
79
84
 
85
+ private
86
+
87
+ def api_key_store
88
+ @api_key_store ||= APIKeyStore.new
89
+ end
90
+
91
+ def normalized_login_provider(provider)
92
+ value = provider.to_s.downcase
93
+ return "openai" if value.empty?
94
+ return "anthropic" if ["anthropic", "claude"].include?(value)
95
+ return "copilot" if ["github", "copilot"].include?(value)
96
+
97
+ ProviderCatalog.fetch(value).id
98
+ end
99
+
100
+ def api_key_login?(provider, auth_method)
101
+ return true if auth_method == :api_key || auth_method == "api_key"
102
+
103
+ ProviderCatalog.fetch(provider).api_key? && !["openai", "anthropic", "copilot"].include?(provider)
104
+ end
105
+
106
+ def login_with_api_key(provider_id)
107
+ provider = ProviderCatalog.fetch(provider_id)
108
+ raise "#{provider.name} does not accept an API key" unless provider.api_key?
109
+
110
+ api_key = @prompt.ask("#{provider.name} API key:").to_s.strip
111
+ provider_config = provider_config_after_api_key_login(provider)
112
+ models = refresh_provider_models(provider, api_key)
113
+ path = api_key_store.store(provider.id, api_key)
114
+ ConfigFiles.update_config(provider_config) unless provider_config.empty?
115
+ @prompt.say("#{colored("Saved", :green, :bold)} #{provider.name} API key to #{path}")
116
+ @prompt.say("Loaded #{models.length} #{provider.name} model#{models.length == 1 ? "" : "s"}.") if models
117
+ end
118
+
119
+ def refresh_provider_models(provider, api_key)
120
+ return unless ProviderCatalog.runtime(provider.id).automatic_model_discovery?
121
+
122
+ model_catalog(provider_id: provider.id, api_key: api_key).refresh
123
+ end
124
+
125
+ def model_catalog(provider_id:, api_key:)
126
+ ModelCatalog.new(provider_id: provider_id, api_key: api_key)
127
+ end
128
+
129
+ def provider_config_after_api_key_login(provider)
130
+ return {} unless provider.id == "azure_openai"
131
+
132
+ AzureOpenAIConfig.new(
133
+ endpoint: @prompt.ask("Azure OpenAI endpoint:"),
134
+ deployment: @prompt.ask("Azure OpenAI deployment name:"),
135
+ api_version: @prompt.ask("Azure OpenAI API version:")
136
+ ).to_config
137
+ end
138
+
80
139
  end
81
140
  end
82
141
  end
@@ -59,6 +59,7 @@ module Kward
59
59
  #{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
60
60
  #{command.call("kward pan")} Start Pan mode web UI
61
61
  #{command.call("kward rpc")} Start the JSON-RPC backend
62
+ #{command.call("kward transport")} Manage transport plugins
62
63
 
63
64
  #{heading.call("Commands")}
64
65
  #{command.call("help")} Show this help
@@ -74,6 +75,7 @@ module Kward
74
75
  #{command.call("openrouter refresh|list")} Refresh or list cached OpenRouter models
75
76
  #{command.call("pan")} Start Pan mode web UI
76
77
  #{command.call("rpc")} Run the JSON-RPC backend for UI clients
78
+ #{command.call("transport list|status|run")} Manage transport plugins
77
79
 
78
80
  #{heading.call("Options")}
79
81
  #{option.call("--working-directory=PATH")} Run Kward from PATH
@@ -163,6 +165,11 @@ module Kward
163
165
  usage: "kward rpc",
164
166
  description: "Start the JSON-RPC backend for trusted local UI clients.",
165
167
  examples: ["kward rpc", "kward --working-directory ~/code/project rpc"]
168
+ },
169
+ "transport" => {
170
+ usage: "kward transport list|status [NAME]|run NAME [WORKSPACE]",
171
+ description: "Inspect or run trusted external transport plugins.",
172
+ examples: ["kward transport list", "kward transport status", "kward transport run telegram", "kward transport run telegram ~/code/project"]
166
173
  }
167
174
  }
168
175
  end
data/lib/kward/cli/git.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "open3"
2
+ require "pathname"
2
3
 
3
4
  # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
@@ -131,20 +132,40 @@ module Kward
131
132
  { path: path, staged: status[0] != " " && status[0] != "?", untracked: status == "??" }
132
133
  end
133
134
 
134
- def git_commit(root, message)
135
- before = git_lifecycle_hook("git_commit_before", root: root, payload: { message: message.to_s })
136
- return { success: false, output: "Declined: #{before.decision.message || "git commit denied"}" } if before.denied? || before.approval_required?
135
+ def git_commit_for_agent(root, message:, paths: nil)
136
+ git_commit(root, message, paths: paths, stage_all: true, run_hooks: false)
137
+ end
138
+
139
+ def git_commit(root, message, paths: nil, stage_all: false, run_hooks: true)
140
+ if run_hooks
141
+ before = git_lifecycle_hook("git_commit_before", root: root, payload: { message: message.to_s })
142
+ return { success: false, output: "Declined: #{before.decision.message || "git commit denied"}" } if before.denied? || before.approval_required?
143
+ end
137
144
 
138
- return git_commit_staged(root, message) if git_staged_changes?(root)
145
+ return git_commit_staged(root, message, run_hooks: run_hooks) if !stage_all && paths.nil? && git_staged_changes?(root)
139
146
 
140
- add_output, add_status = Open3.capture2e("git", "add", "--all", chdir: root.to_s)
147
+ add_arguments = ["git", "add", "--all"]
148
+ add_arguments.concat(["--", *validated_git_commit_paths(root, paths)]) if paths
149
+ add_output, add_status = Open3.capture2e(*add_arguments, chdir: root.to_s)
141
150
  return { success: false, output: add_output } unless add_status.success?
142
151
 
143
- git_commit_staged(root, message)
152
+ git_commit_staged(root, message, run_hooks: run_hooks)
144
153
  rescue StandardError => e
145
154
  { success: false, output: e.message }
146
155
  end
147
156
 
157
+ def validated_git_commit_paths(root, paths)
158
+ Array(paths).map do |path|
159
+ value = path.to_s
160
+ expanded = File.expand_path(value, root.to_s)
161
+ unless !value.empty? && !Pathname.new(value).absolute? && (expanded == root.to_s || expanded.start_with?("#{root}/"))
162
+ raise ArgumentError, "Git commit path must be workspace-relative: #{value}"
163
+ end
164
+
165
+ value
166
+ end
167
+ end
168
+
148
169
  def git_staged_changes?(root)
149
170
  _output, status = Open3.capture2e("git", "diff", "--cached", "--quiet", chdir: root.to_s)
150
171
  !status.success?
@@ -152,10 +173,12 @@ module Kward
152
173
  false
153
174
  end
154
175
 
155
- def git_commit_staged(root, message)
176
+ def git_commit_staged(root, message, run_hooks: true)
156
177
  commit_output, commit_status = Open3.capture2e("git", "commit", "-m", message.to_s, chdir: root.to_s)
157
178
  result = { success: commit_status.success?, output: commit_output }
158
- git_lifecycle_hook("git_commit_after", root: root, payload: { message: message.to_s, success: result[:success], output: result[:output] })
179
+ if run_hooks
180
+ git_lifecycle_hook("git_commit_after", root: root, payload: { message: message.to_s, success: result[:success], output: result[:output] })
181
+ end
159
182
  result
160
183
  rescue StandardError => e
161
184
  { success: false, output: e.message }
@@ -104,7 +104,7 @@ module Kward
104
104
  def prepare_memory_context(conversation, input)
105
105
  Memory::TurnContext.apply(conversation: conversation, input: input)
106
106
  rescue StandardError => e
107
- warn "Memory retrieval failed: #{e.message}"
107
+ emit_warning "Memory retrieval failed: #{e.message}"
108
108
  nil
109
109
  end
110
110
 
@@ -120,7 +120,7 @@ module Kward
120
120
 
121
121
  summarize_memory(conversation, manager: manager)
122
122
  rescue StandardError => e
123
- warn "Memory auto-summary failed: #{e.message}"
123
+ emit_warning "Memory auto-summary failed: #{e.message}"
124
124
  nil
125
125
  end
126
126
 
@@ -31,6 +31,7 @@ module Kward
31
31
  end
32
32
 
33
33
  def reload_plugins(conversation)
34
+ @prompt_templates = nil
34
35
  @plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
35
36
  @prompt.update_slash_commands(slash_command_entries) if @prompt.respond_to?(:update_slash_commands)
36
37
  conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
@@ -15,6 +15,8 @@ module Kward
15
15
  prompt_interface = load_prompt_interface
16
16
  return unless prompt_interface
17
17
 
18
+ @interactive_warning_sink_active = true
19
+ ConfigFiles.warning_sink = interactive_warning_sink
18
20
  @prompt = prompt_interface.new(
19
21
  slash_commands: slash_command_entries,
20
22
  overlay_settings: ConfigFiles.overlay_settings,
@@ -27,6 +29,7 @@ module Kward
27
29
  banner_message: Kward::PromptInterface::BANNER_MESSAGE,
28
30
  tab_keybindings: ConfigFiles.composer_tab_keybindings,
29
31
  prompt_history: PromptHistory.new(cwd: current_workspace_root),
32
+ workspace_root: current_workspace_root,
30
33
  editor_mode: ConfigFiles.editor_mode,
31
34
  editor_mode_source: -> { ConfigFiles.editor_mode },
32
35
  editor_auto_indent: ConfigFiles.editor_auto_indent?,
@@ -47,6 +50,28 @@ module Kward
47
50
  else
48
51
  @prompt.start
49
52
  end
53
+ flush_interactive_warnings
54
+ end
55
+
56
+ def interactive_warning_sink
57
+ @interactive_warning_sink ||= lambda do |message|
58
+ unless @interactive_warning_sink_active
59
+ warn message
60
+ next
61
+ end
62
+
63
+ if prompt_interface?
64
+ runtime_output(message)
65
+ else
66
+ (@pending_interactive_warnings ||= []) << message
67
+ end
68
+ end
69
+ end
70
+
71
+ def flush_interactive_warnings
72
+ warnings = @pending_interactive_warnings
73
+ @pending_interactive_warnings = []
74
+ Array(warnings).each { |message| runtime_output(message) }
50
75
  end
51
76
 
52
77
  def load_prompt_interface
@@ -67,6 +92,24 @@ module Kward
67
92
  @prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
68
93
  end
69
94
 
95
+ def emit_warning(message)
96
+ sink = ConfigFiles.warning_sink
97
+ sink ? sink.call(message) : warn(message)
98
+ end
99
+
100
+ def clear_interactive_warning_sink
101
+ ConfigFiles.warning_sink = nil
102
+ @interactive_warning_sink_active = false
103
+ @pending_interactive_warnings = nil
104
+ @interactive_warning_sink = nil
105
+ end
106
+
107
+ def update_prompt_workspace_root(root)
108
+ return unless @prompt.respond_to?(:update_workspace_root)
109
+
110
+ @prompt.update_workspace_root(root, prompt_history: PromptHistory.new(cwd: root))
111
+ end
112
+
70
113
  # Writes the startup info screen output for the terminal CLI flow.
71
114
  def print_visual_banner
72
115
  return unless @prompt.respond_to?(:print_visual_banner)
@@ -172,7 +215,7 @@ module Kward
172
215
  context = plugin_context(current_footer_conversation, "")
173
216
  renderer.call(context).to_s
174
217
  rescue StandardError => e
175
- warn "Warning: Kward plugin footer error: #{e.message}"
218
+ emit_warning "Warning: Kward plugin footer error: #{e.message}"
176
219
  ""
177
220
  end
178
221
  end
@@ -272,8 +272,12 @@ module Kward
272
272
  # Writes the block delta output for the terminal CLI flow.
273
273
  def print_block_delta(label, delta)
274
274
  if prompt_interface?
275
- @prompt.start_stream_block(label)
276
- @prompt.write_delta(delta)
275
+ if @prompt.respond_to?(:write_stream_block)
276
+ @prompt.write_stream_block(label, delta, finish: false)
277
+ else
278
+ @prompt.start_stream_block(label)
279
+ @prompt.write_delta(delta)
280
+ end
277
281
  else
278
282
  start_stream_block(label)
279
283
  print delta
@@ -60,6 +60,7 @@ module Kward
60
60
  client: @client,
61
61
  tool_registry: tool_registry,
62
62
  conversation: conversation,
63
+ warning_sink: ConfigFiles.warning_sink,
63
64
  hook_manager: hook_manager,
64
65
  hook_context: hook_context
65
66
  )
@@ -317,8 +318,8 @@ module Kward
317
318
  @pending_inputs&.first.is_a?(Hash) && @pending_inputs.first[:tab_action]
318
319
  end
319
320
 
320
- def configured_workspace(root: current_workspace_root)
321
- WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: safely_read_config.to_h)
321
+ def configured_workspace(root: current_workspace_root, strict: false)
322
+ WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: safely_read_config.to_h, strict: strict)
322
323
  end
323
324
 
324
325
  def workspace_guardrails_enabled?
@@ -401,6 +402,8 @@ module Kward
401
402
  end
402
403
 
403
404
  def current_workspace_root
405
+ conversation = active_tab&.agent&.conversation if respond_to?(:active_tab, true)
406
+ return conversation.workspace_root if conversation&.respond_to?(:workspace_root)
404
407
  return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
405
408
  return @working_directory if @working_directory
406
409
 
@@ -103,7 +103,7 @@ module Kward
103
103
  @active_session = track_session(session_store.create)
104
104
  reset_session_diff
105
105
  cleanup_replaced_session(previous_session)
106
- conversation = new_conversation(workspace_root: session_store.cwd)
106
+ conversation = new_conversation(workspace_root: current_workspace_root)
107
107
  @active_session.attach(conversation)
108
108
  run_lifecycle_hook("session_create", conversation: conversation, payload: { action: "create" })
109
109
  update_assistant_prompt(conversation)
@@ -120,7 +120,10 @@ module Kward
120
120
 
121
121
  def provider_choices
122
122
  current = current_model_provider
123
- ["Codex", "Anthropic", "OpenRouter", "Copilot", "Local"].map do |provider|
123
+ configured_api_key_providers = ProviderCatalog.api_key_providers.filter_map do |provider|
124
+ provider.name if api_key_store.configured?(provider.id)
125
+ end
126
+ (["Codex", "Anthropic", "OpenRouter", "Copilot", "Local"] + configured_api_key_providers).uniq.map do |provider|
124
127
  label = provider.dup
125
128
  label += " (current)" if provider == current
126
129
  label
@@ -135,7 +138,7 @@ module Kward
135
138
  return "Copilot" if text.start_with?("copilot")
136
139
  return "Local" if text.start_with?("local")
137
140
 
138
- nil
141
+ ProviderCatalog.find_by_name(selected)&.name
139
142
  end
140
143
 
141
144
  def configure_local_server_preset(conversation)
@@ -721,12 +724,15 @@ module Kward
721
724
  return
722
725
  end
723
726
 
724
- selected = @prompt.select("OAuth provider", login_provider_choices, title: "Login")
727
+ method = selected_login_method(@prompt.select("How would you like to connect?", login_method_choices, title: "Login"))
728
+ return unless method
729
+
730
+ selected = @prompt.select(login_provider_prompt(method), login_provider_choices(method), title: "Login")
725
731
  provider = selected_login_provider(selected)
726
732
  return unless provider
727
733
 
728
734
  run_busy_local_command_and_requeue(activity: "running") do
729
- login(provider: provider)
735
+ login(provider: provider, auth_method: method)
730
736
  reload_client_config
731
737
  end
732
738
  rescue StandardError => e
@@ -740,17 +746,40 @@ module Kward
740
746
  end
741
747
 
742
748
  models ||= normalized_available_models(conversation)
743
- choices = model_choices(models, conversation)
744
- selected = @prompt.select("Default model", choices, title: "Models", custom: true)
745
- return unless selected
746
-
747
- provider, model = selected_model(selected, models)
748
- raise "Model must be a non-empty string" if model.to_s.strip.empty?
749
-
750
- ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
751
- reload_client_config
752
- refresh_conversation_runtime(conversation)
753
- @prompt.redraw if @prompt.respond_to?(:redraw)
749
+ provider_filter = nil
750
+ show_all = false
751
+ loop do
752
+ visible_models = provider_filter ? models.select { |model| model[:provider] == provider_filter } : models
753
+ visible_models = visible_models.select { |model| default_selectable_model?(model) } unless show_all
754
+ actions = ["Refresh model list", "Enter model ID manually", "Show all models", "Change provider"]
755
+ selected = @prompt.select("Default model", model_choices(visible_models, conversation) + actions, title: "Models", custom: true)
756
+ return unless selected
757
+
758
+ case selected.to_s
759
+ when "Refresh model list"
760
+ refreshed = @client.refresh_available_models(provider: provider_filter) if @client.respond_to?(:refresh_available_models)
761
+ models = normalized_available_models(conversation, models: refreshed)
762
+ when "Enter model ID manually"
763
+ provider = provider_filter || conversation&.provider || current_model_provider
764
+ model = @prompt.select("Model ID for #{provider}", [], title: "Models", custom: true)
765
+ return unless model
766
+ persist_model_selection(provider, model, conversation)
767
+ return
768
+ when "Show all models"
769
+ provider_filter = nil
770
+ show_all = true
771
+ when "Change provider"
772
+ selected_provider_name = selected_provider(@prompt.select("Provider", provider_choices, title: "Models"))
773
+ if selected_provider_name
774
+ provider_filter = selected_provider_name
775
+ show_all = false
776
+ end
777
+ else
778
+ provider, model = selected_model(selected, models)
779
+ persist_model_selection(provider, model, conversation)
780
+ return
781
+ end
782
+ end
754
783
  rescue StandardError => e
755
784
  runtime_output("Model error: #{e.message}")
756
785
  end
@@ -784,23 +813,38 @@ module Kward
784
813
  @prompt.respond_to?(:select)
785
814
  end
786
815
 
787
- def login_provider_choices
788
- ["OpenAI", "Anthropic", "OpenRouter", "GitHub"]
816
+ def login_method_choices
817
+ ["API key", "Subscription / OAuth"]
789
818
  end
790
819
 
791
- def selected_login_provider(selected)
820
+ def selected_login_method(selected)
792
821
  case selected.to_s.downcase
793
- when /\Aopenai\b/
794
- "openai"
795
- when /\Aanthropic\b/, /\Aclaude\b/
796
- "anthropic"
797
- when /\Aopenrouter\b/
798
- "openrouter"
799
- when /\Agithub\b/
800
- "github"
822
+ when /\Aapi key\z/ then :api_key
823
+ when /\Asubscription \/ oauth\z/ then :oauth
824
+ end
825
+ end
826
+
827
+ def login_provider_prompt(method)
828
+ method == :api_key ? "Add an API key" : "Sign in with a subscription"
829
+ end
830
+
831
+ def login_provider_choices(method)
832
+ if method == :api_key
833
+ ProviderCatalog.api_key_providers.map(&:name)
834
+ else
835
+ ["Anthropic Claude", "ChatGPT", "GitHub Copilot"]
801
836
  end
802
837
  end
803
838
 
839
+ def selected_login_provider(selected)
840
+ value = selected.to_s.downcase
841
+ return "openai" if value == "openai" || value == "chatgpt"
842
+ return "anthropic" if value == "anthropic" || value == "anthropic claude"
843
+ return "copilot" if value == "github copilot"
844
+
845
+ ProviderCatalog.find_by_name(selected)&.id
846
+ end
847
+
804
848
  def model_overlay_available?
805
849
  @prompt.respond_to?(:select)
806
850
  end
@@ -822,11 +866,11 @@ module Kward
822
866
  values.find { |value| choice.to_s.downcase.start_with?(value) }
823
867
  end
824
868
 
825
- def normalized_available_models(conversation = current_footer_conversation)
869
+ def normalized_available_models(conversation = current_footer_conversation, models: nil)
826
870
  current_provider = conversation.provider || (@client.respond_to?(:current_provider) ? @client.current_provider : "Codex")
827
871
  current_model = conversation.model || (@client.respond_to?(:current_model) ? @client.current_model : nil)
828
872
  current_reasoning = conversation.reasoning_effort || (@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil)
829
- models = @client.respond_to?(:available_models) ? @client.available_models : []
873
+ models ||= @client.respond_to?(:available_models) ? @client.available_models : []
830
874
  ModelInfo.normalize_available(
831
875
  models,
832
876
  current_provider: current_provider,
@@ -846,17 +890,37 @@ module Kward
846
890
  choices.empty? ? ["#{current_provider} #{current_model} (current)"] : choices.uniq
847
891
  end
848
892
 
893
+ def default_selectable_model?(model)
894
+ return true if model[:current]
895
+
896
+ capabilities = Array(model[:supportedParameters]).map { |value| value.to_s.downcase }
897
+ return true if capabilities.empty?
898
+
899
+ capabilities.any? do |capability|
900
+ ["tools", "toolchoice", "functioncalling", "generatecontent", "streamgeneratecontent"].include?(capability.delete("_-"))
901
+ end
902
+ end
903
+
849
904
  def selected_model(selected, models)
850
905
  text = selected.to_s.sub(/ \(current\)\z/, "").strip
851
906
  known = models.find { |model| "#{model[:provider]} #{model[:id]}" == text }
852
907
  return [known[:provider], known[:id]] if known
853
908
 
854
909
  provider, model = text.split(/\s+/, 2)
855
- if ["Codex", "Anthropic", "OpenRouter", "Copilot"].include?(provider) && !model.to_s.strip.empty?
856
- [provider, model.strip]
857
- else
858
- [current_model_provider, text]
859
- end
910
+ known_provider = ["Codex", "Anthropic", "OpenRouter", "Copilot", "Local"].include?(provider) || ProviderCatalog.find_by_name(provider)
911
+ return [provider, model.strip] if known_provider && !model.to_s.strip.empty?
912
+
913
+ [current_model_provider, text]
914
+ end
915
+
916
+ def persist_model_selection(provider, model, conversation)
917
+ model = model.to_s.strip
918
+ raise "Model must be a non-empty string" if model.empty?
919
+
920
+ ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
921
+ reload_client_config
922
+ refresh_conversation_runtime(conversation)
923
+ @prompt.redraw if @prompt.respond_to?(:redraw)
860
924
  end
861
925
 
862
926
  REASONING_CONFIG_DEBOUNCE_SECONDS = 0.5
@@ -345,7 +345,7 @@ module Kward
345
345
  percent = ((reserve_tokens.to_f / context_window.to_i) * 100).round(1)
346
346
  "Auto-compaction reserve: #{reserve_tokens} tokens (#{percent}% of #{context_window})"
347
347
  rescue StandardError => e
348
- warn "Auto-compaction status unavailable: #{e.message}"
348
+ emit_warning "Auto-compaction status unavailable: #{e.message}"
349
349
  nil
350
350
  end
351
351