kward 0.79.0 → 0.80.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +6 -0
- data/CHANGELOG.md +58 -1
- data/Gemfile.lock +2 -2
- data/README.md +31 -38
- data/doc/agent-tools.md +9 -10
- data/doc/api.md +3 -3
- data/doc/authentication.md +79 -110
- data/doc/code-search.md +1 -1
- data/doc/composer.md +1 -1
- data/doc/configuration.md +69 -26
- data/doc/context-budgeting.md +6 -6
- data/doc/context-tools.md +2 -2
- data/doc/editor.md +7 -7
- data/doc/extensibility.md +7 -29
- data/doc/files.md +6 -6
- data/doc/getting-started.md +2 -2
- data/doc/git.md +8 -2
- data/doc/lifecycle-hooks.md +5 -4
- data/doc/local-models.md +2 -2
- data/doc/mcp.md +5 -5
- data/doc/permissions.md +13 -4
- data/doc/plugins.md +39 -4
- data/doc/prompt-templates.md +105 -0
- data/doc/providers.md +140 -0
- data/doc/releasing.md +10 -2
- data/doc/rpc.md +54 -31
- data/doc/sandboxing.md +22 -0
- data/doc/security.md +22 -7
- data/doc/session-management.md +2 -4
- data/doc/tabs.md +37 -0
- data/doc/telegram.md +138 -0
- data/doc/transports.md +209 -0
- data/doc/usage.md +11 -3
- data/doc/web-search.md +3 -3
- data/doc/workspace-tools.md +2 -2
- data/examples/plugins/stardate_footer.rb +10 -0
- data/examples/plugins/telegram/plugin.rb +39 -0
- data/examples/plugins/telegram/telegram_api.rb +136 -0
- data/examples/plugins/telegram/telegram_transport.rb +304 -0
- data/lib/kward/ansi.rb +1 -0
- data/lib/kward/auth/api_key_store.rb +99 -0
- data/lib/kward/cli/auth_commands.rb +72 -13
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +31 -8
- data/lib/kward/cli/plugins.rb +1 -0
- data/lib/kward/cli/prompt_interface.rb +7 -0
- data/lib/kward/cli/rendering.rb +6 -2
- data/lib/kward/cli/runtime_helpers.rb +4 -2
- data/lib/kward/cli/sessions.rb +1 -1
- data/lib/kward/cli/settings.rb +97 -33
- data/lib/kward/cli/tabs.rb +102 -37
- data/lib/kward/cli/transports.rb +67 -0
- data/lib/kward/cli/worktrees.rb +360 -0
- data/lib/kward/cli.rb +20 -0
- data/lib/kward/config_files.rb +26 -3
- data/lib/kward/conversation.rb +13 -7
- data/lib/kward/git_worktree_manager.rb +291 -0
- data/lib/kward/model/azure_openai_config.rb +54 -0
- data/lib/kward/model/catalog.rb +113 -0
- data/lib/kward/model/client.rb +225 -20
- data/lib/kward/model/model_info.rb +49 -0
- data/lib/kward/model/payloads.rb +63 -4
- data/lib/kward/model/provider_catalog.rb +114 -0
- data/lib/kward/model/sources.rb +70 -0
- data/lib/kward/model/stream_parser.rb +83 -13
- data/lib/kward/permissions/policy.rb +18 -4
- data/lib/kward/plugin_chat_runtime.rb +374 -0
- data/lib/kward/plugin_registry.rb +80 -7
- data/lib/kward/private_file.rb +9 -3
- data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
- data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
- data/lib/kward/prompt_interface/editor/controller.rb +7 -7
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
- data/lib/kward/prompt_interface/editor/state.rb +3 -9
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
- data/lib/kward/prompt_interface/file_overlay.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +7 -3
- data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
- data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
- data/lib/kward/prompt_interface/interactive/state.rb +2 -2
- data/lib/kward/prompt_interface/project_browser.rb +25 -6
- data/lib/kward/prompt_interface/runtime_state.rb +22 -6
- data/lib/kward/prompt_interface/screen.rb +3 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
- data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
- data/lib/kward/prompt_interface.rb +53 -12
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +6 -5
- data/lib/kward/rpc/auth_manager.rb +112 -152
- data/lib/kward/rpc/config_manager.rb +46 -6
- data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
- data/lib/kward/rpc/prompt_bridge.rb +8 -4
- data/lib/kward/rpc/redactor.rb +1 -1
- data/lib/kward/rpc/server.rb +60 -10
- data/lib/kward/rpc/session_manager.rb +109 -30
- data/lib/kward/session_store.rb +84 -24
- data/lib/kward/tab_driver.rb +6 -3
- data/lib/kward/tools/git_commit.rb +39 -0
- data/lib/kward/tools/registry.rb +7 -2
- data/lib/kward/tools/tool_call.rb +1 -0
- data/lib/kward/transport/gateway.rb +253 -0
- data/lib/kward/transport/host.rb +343 -0
- data/lib/kward/transport/manager.rb +179 -0
- data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
- data/lib/kward/transport/runtime.rb +41 -0
- data/lib/kward/transport/store.rb +101 -0
- data/lib/kward/transport.rb +204 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace_factory.rb +19 -2
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/setup.rb +4 -0
- metadata +26 -1
data/lib/kward/cli/commands.rb
CHANGED
|
@@ -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
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
data/lib/kward/cli/plugins.rb
CHANGED
|
@@ -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=)
|
|
@@ -27,6 +27,7 @@ module Kward
|
|
|
27
27
|
banner_message: Kward::PromptInterface::BANNER_MESSAGE,
|
|
28
28
|
tab_keybindings: ConfigFiles.composer_tab_keybindings,
|
|
29
29
|
prompt_history: PromptHistory.new(cwd: current_workspace_root),
|
|
30
|
+
workspace_root: current_workspace_root,
|
|
30
31
|
editor_mode: ConfigFiles.editor_mode,
|
|
31
32
|
editor_mode_source: -> { ConfigFiles.editor_mode },
|
|
32
33
|
editor_auto_indent: ConfigFiles.editor_auto_indent?,
|
|
@@ -67,6 +68,12 @@ module Kward
|
|
|
67
68
|
@prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
|
|
68
69
|
end
|
|
69
70
|
|
|
71
|
+
def update_prompt_workspace_root(root)
|
|
72
|
+
return unless @prompt.respond_to?(:update_workspace_root)
|
|
73
|
+
|
|
74
|
+
@prompt.update_workspace_root(root, prompt_history: PromptHistory.new(cwd: root))
|
|
75
|
+
end
|
|
76
|
+
|
|
70
77
|
# Writes the startup info screen output for the terminal CLI flow.
|
|
71
78
|
def print_visual_banner
|
|
72
79
|
return unless @prompt.respond_to?(:print_visual_banner)
|
data/lib/kward/cli/rendering.rb
CHANGED
|
@@ -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.
|
|
276
|
-
|
|
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
|
|
@@ -317,8 +317,8 @@ module Kward
|
|
|
317
317
|
@pending_inputs&.first.is_a?(Hash) && @pending_inputs.first[:tab_action]
|
|
318
318
|
end
|
|
319
319
|
|
|
320
|
-
def configured_workspace(root: current_workspace_root)
|
|
321
|
-
WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: safely_read_config.to_h)
|
|
320
|
+
def configured_workspace(root: current_workspace_root, strict: false)
|
|
321
|
+
WorkspaceFactory.build(root: root, guardrails: workspace_guardrails_enabled?, config: safely_read_config.to_h, strict: strict)
|
|
322
322
|
end
|
|
323
323
|
|
|
324
324
|
def workspace_guardrails_enabled?
|
|
@@ -401,6 +401,8 @@ module Kward
|
|
|
401
401
|
end
|
|
402
402
|
|
|
403
403
|
def current_workspace_root
|
|
404
|
+
conversation = active_tab&.agent&.conversation if respond_to?(:active_tab, true)
|
|
405
|
+
return conversation.workspace_root if conversation&.respond_to?(:workspace_root)
|
|
404
406
|
return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
|
|
405
407
|
return @working_directory if @working_directory
|
|
406
408
|
|
data/lib/kward/cli/sessions.rb
CHANGED
|
@@ -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:
|
|
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)
|
data/lib/kward/cli/settings.rb
CHANGED
|
@@ -120,7 +120,10 @@ module Kward
|
|
|
120
120
|
|
|
121
121
|
def provider_choices
|
|
122
122
|
current = current_model_provider
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
788
|
-
["
|
|
816
|
+
def login_method_choices
|
|
817
|
+
["API key", "Subscription / OAuth"]
|
|
789
818
|
end
|
|
790
819
|
|
|
791
|
-
def
|
|
820
|
+
def selected_login_method(selected)
|
|
792
821
|
case selected.to_s.downcase
|
|
793
|
-
when /\
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
data/lib/kward/cli/tabs.rb
CHANGED
|
@@ -8,6 +8,12 @@ module Kward
|
|
|
8
8
|
class CLI
|
|
9
9
|
# TUI session tab coordination and asynchronous turn execution.
|
|
10
10
|
module Tabs
|
|
11
|
+
WORKTREE_AGENT_CONTEXT = <<~PROMPT.strip.freeze
|
|
12
|
+
This is an active Git worktree tab. The workspace is sandboxed separately from the host repository metadata.
|
|
13
|
+
When the user explicitly asks you to commit, use the advertised `git_commit` tool. Do not use `run_shell_command` for `git add` or `git commit`, because shared Git metadata is intentionally protected there.
|
|
14
|
+
`git_commit` requires a commit message and accepts optional workspace-relative paths; omit paths to include all current changes in this worktree. Do not create a commit unless the user requested one.
|
|
15
|
+
PROMPT
|
|
16
|
+
|
|
11
17
|
TabRuntime = Struct.new(
|
|
12
18
|
:session,
|
|
13
19
|
:agent,
|
|
@@ -91,6 +97,7 @@ module Kward
|
|
|
91
97
|
@active_tab_index = 0
|
|
92
98
|
@tab_store = session_store ? TabStore.new(config_dir: session_store.config_dir, cwd: session_store.cwd) : nil
|
|
93
99
|
@tab_live_view = nil
|
|
100
|
+
@tab_live_view_token = nil
|
|
94
101
|
@restored_tabs = false
|
|
95
102
|
restored = restore_tabs(session_store) if session_store && agent.nil?
|
|
96
103
|
return restored if restored
|
|
@@ -120,11 +127,14 @@ module Kward
|
|
|
120
127
|
descriptors = Array(data["tabs"])
|
|
121
128
|
return nil if descriptors.empty?
|
|
122
129
|
|
|
123
|
-
descriptors.each_with_index do |
|
|
124
|
-
tab = build_tab_from_descriptor(
|
|
130
|
+
descriptors.each_with_index do |descriptor, index|
|
|
131
|
+
tab = build_tab_from_descriptor(descriptor, session_store, label: restored_tab_label(data, index))
|
|
125
132
|
@tabs << tab if tab
|
|
126
|
-
rescue StandardError
|
|
127
|
-
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
descriptor = descriptor.transform_keys(&:to_s) if descriptor.respond_to?(:transform_keys)
|
|
135
|
+
if descriptor.is_a?(Hash) && descriptor.key?("worktree")
|
|
136
|
+
@tabs << unavailable_worktree_tab(descriptor, e, label: restored_tab_label(data, index))
|
|
137
|
+
end
|
|
128
138
|
end
|
|
129
139
|
return nil if @tabs.empty?
|
|
130
140
|
|
|
@@ -136,13 +146,21 @@ module Kward
|
|
|
136
146
|
def build_tab_from_descriptor(descriptor, session_store, label: nil)
|
|
137
147
|
descriptor = descriptor.transform_keys(&:to_s)
|
|
138
148
|
if descriptor["kind"] == "session"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
149
|
+
worktree = if descriptor.key?("worktree")
|
|
150
|
+
binding = GitWorktreeBinding.from_descriptor(descriptor["worktree"])
|
|
151
|
+
raise GitWorktreeManager::Error, "Invalid worktree binding." unless binding
|
|
152
|
+
|
|
153
|
+
binding
|
|
154
|
+
end
|
|
155
|
+
validate_worktree_binding!(worktree)
|
|
156
|
+
root = worktree_root_for(worktree, fallback: session_store.cwd)
|
|
157
|
+
session, conversation = restore_tab_session(session_store, descriptor.fetch("session_path"), workspace_root: root, strict: worktree&.active? == true)
|
|
158
|
+
agent = build_tab_agent(conversation, session, worktree: worktree)
|
|
159
|
+
return build_tab(session, agent, driver: SessionTabDriver.new(session: session, agent: agent, worktree: worktree), label: label)
|
|
142
160
|
end
|
|
143
161
|
|
|
144
162
|
tab_type = plugin_registry.tab_type_for_id(descriptor["plugin_tab_type"])
|
|
145
|
-
driver = if tab_type
|
|
163
|
+
driver = if tab_type&.local
|
|
146
164
|
host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
|
|
147
165
|
tab_type.handler.call(host, descriptor)
|
|
148
166
|
else
|
|
@@ -154,7 +172,7 @@ module Kward
|
|
|
154
172
|
|
|
155
173
|
def open_plugin_tab(name, session_store)
|
|
156
174
|
tab_type = plugin_registry.tab_type_for(name)
|
|
157
|
-
return runtime_output("Plugin tab #{name.inspect} is not available.") unless tab_type
|
|
175
|
+
return runtime_output("Plugin tab #{name.inspect} is not available.") unless tab_type&.local
|
|
158
176
|
|
|
159
177
|
if tab_type.singleton == :global && (existing = @tabs.find { |tab| tab.driver.descriptor["plugin_tab_type"] == tab_type.id })
|
|
160
178
|
return switch_tab(@tabs.index(existing))
|
|
@@ -174,30 +192,37 @@ module Kward
|
|
|
174
192
|
runtime_output("Could not open plugin tab #{name.inspect}: #{e.message}")
|
|
175
193
|
end
|
|
176
194
|
|
|
177
|
-
def restore_tab_session(session_store, path)
|
|
195
|
+
def restore_tab_session(session_store, path, workspace_root: session_store.cwd, strict: false)
|
|
178
196
|
if File.file?(path)
|
|
179
|
-
session, conversation = session_store.load(path, workspace: configured_workspace(root:
|
|
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)
|
|
180
198
|
return [track_session(session), conversation]
|
|
181
199
|
end
|
|
182
200
|
|
|
183
201
|
session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
|
|
184
|
-
conversation = new_conversation(workspace_root:
|
|
202
|
+
conversation = new_conversation(workspace_root: workspace_root)
|
|
185
203
|
session.attach(conversation)
|
|
186
204
|
[session, conversation]
|
|
187
205
|
end
|
|
188
206
|
|
|
189
|
-
def build_tab_agent(conversation, _session)
|
|
207
|
+
def build_tab_agent(conversation, _session, worktree: nil)
|
|
190
208
|
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
191
|
-
|
|
209
|
+
strict = worktree&.active? == true
|
|
210
|
+
update_worktree_agent_context(conversation, strict)
|
|
211
|
+
workspace = configured_workspace(root: conversation.workspace_root, strict: strict)
|
|
192
212
|
prompt = TabQuestionPrompt.new(self)
|
|
193
|
-
hook_manager = lifecycle_hook_manager(conversation)
|
|
194
|
-
hook_context = lifecycle_hook_context(conversation)
|
|
213
|
+
hook_manager = strict ? nil : lifecycle_hook_manager(conversation)
|
|
214
|
+
hook_context = strict ? nil : lifecycle_hook_context(conversation)
|
|
215
|
+
git_committer = if strict
|
|
216
|
+
->(message:, paths:) { git_commit_for_agent(workspace.root.to_s, message: message, paths: paths) }
|
|
217
|
+
end
|
|
195
218
|
tool_registry = ToolRegistry.new(
|
|
196
219
|
workspace: workspace,
|
|
197
220
|
prompt: prompt,
|
|
198
221
|
tool_approval: tab_tool_approval_callback(prompt),
|
|
199
222
|
hook_manager: hook_manager,
|
|
200
|
-
hook_context: hook_context
|
|
223
|
+
hook_context: hook_context,
|
|
224
|
+
mcp_clients: strict ? [] : nil,
|
|
225
|
+
git_committer: git_committer
|
|
201
226
|
)
|
|
202
227
|
@footer_conversation = conversation
|
|
203
228
|
@footer_tool_registry = tool_registry
|
|
@@ -212,6 +237,14 @@ module Kward
|
|
|
212
237
|
agent
|
|
213
238
|
end
|
|
214
239
|
|
|
240
|
+
def update_worktree_agent_context(conversation, strict)
|
|
241
|
+
desired = strict ? WORKTREE_AGENT_CONTEXT : nil
|
|
242
|
+
return if conversation.execution_profile_context == desired
|
|
243
|
+
return unless strict || conversation.execution_profile_context == WORKTREE_AGENT_CONTEXT
|
|
244
|
+
|
|
245
|
+
conversation.update_execution_profile_context!(desired)
|
|
246
|
+
end
|
|
247
|
+
|
|
215
248
|
def tab_tool_approval_callback(prompt)
|
|
216
249
|
return nil unless ConfigFiles.permission_policy(safely_read_config.to_h).enabled?
|
|
217
250
|
|
|
@@ -372,8 +405,9 @@ module Kward
|
|
|
372
405
|
return agent unless tab
|
|
373
406
|
|
|
374
407
|
tab.session = @active_session
|
|
375
|
-
|
|
376
|
-
tab.
|
|
408
|
+
worktree = reconcile_worktree_binding(tab, agent.conversation)
|
|
409
|
+
tab.agent = build_tab_agent(agent.conversation, tab.session, worktree: worktree)
|
|
410
|
+
tab.driver = SessionTabDriver.new(session: tab.session, agent: tab.agent, worktree: worktree)
|
|
377
411
|
assign_tab_question_prompt(tab.agent, tab)
|
|
378
412
|
tab.diff = @session_diff || (tab.session&.path ? SessionDiff.from_session_file(tab.session.path) : SessionDiff.new)
|
|
379
413
|
tab.snapshot = nil
|
|
@@ -389,6 +423,7 @@ module Kward
|
|
|
389
423
|
tab.shell = nil
|
|
390
424
|
tab.stream_state = new_tab_stream_state(tab.driver)
|
|
391
425
|
tab.markdown_chunks.clear
|
|
426
|
+
update_prompt_workspace_root(current_workspace_root)
|
|
392
427
|
update_prompt_tabs
|
|
393
428
|
persist_tabs
|
|
394
429
|
tab.agent
|
|
@@ -399,13 +434,23 @@ module Kward
|
|
|
399
434
|
return unless tab
|
|
400
435
|
|
|
401
436
|
if @prompt.respond_to?(:tab_view_snapshot)
|
|
402
|
-
tab.snapshot =
|
|
437
|
+
tab.snapshot = tab_view_snapshot_for(tab)
|
|
403
438
|
elsif @prompt.respond_to?(:composer_snapshot)
|
|
404
439
|
tab.snapshot = @prompt.composer_snapshot
|
|
405
440
|
end
|
|
406
441
|
tab.diff = @session_diff
|
|
407
442
|
end
|
|
408
443
|
|
|
444
|
+
def tab_view_snapshot_for(tab)
|
|
445
|
+
snapshot_method = @prompt.method(:tab_view_snapshot)
|
|
446
|
+
supports_transcript_option = snapshot_method.parameters.any? do |type, name|
|
|
447
|
+
type == :keyrest || ([:key, :keyreq].include?(type) && name == :include_transcript)
|
|
448
|
+
end
|
|
449
|
+
return snapshot_method.call unless supports_transcript_option
|
|
450
|
+
|
|
451
|
+
snapshot_method.call(include_transcript: tab.running? || !tab.shell.nil?)
|
|
452
|
+
end
|
|
453
|
+
|
|
409
454
|
def activate_tab(index, render: true)
|
|
410
455
|
tab = @tabs[index]
|
|
411
456
|
return nil unless tab
|
|
@@ -424,6 +469,8 @@ module Kward
|
|
|
424
469
|
end
|
|
425
470
|
tab.unread = false
|
|
426
471
|
restore_tab_composer_snapshot(tab.snapshot)
|
|
472
|
+
update_prompt_workspace_root(current_workspace_root)
|
|
473
|
+
@prompt.refresh_composer_status if @prompt.respond_to?(:refresh_composer_status)
|
|
427
474
|
update_plugin_tab_slash_commands(tab)
|
|
428
475
|
update_prompt_tabs
|
|
429
476
|
render_tab(tab) if render
|
|
@@ -640,12 +687,12 @@ module Kward
|
|
|
640
687
|
|
|
641
688
|
def refresh_active_tab
|
|
642
689
|
tab = active_tab
|
|
643
|
-
return unless tab
|
|
690
|
+
return unless tab&.thread && !tab.thread.alive? && tab.running?
|
|
644
691
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
692
|
+
tab.thread.join
|
|
693
|
+
return unless tab.status == "running"
|
|
694
|
+
|
|
695
|
+
tab.status = "ready"
|
|
649
696
|
update_prompt_tabs
|
|
650
697
|
end
|
|
651
698
|
|
|
@@ -653,23 +700,31 @@ module Kward
|
|
|
653
700
|
return unless prompt_interface?
|
|
654
701
|
|
|
655
702
|
stop_tab_live_view
|
|
656
|
-
|
|
657
|
-
@
|
|
703
|
+
token = Object.new
|
|
704
|
+
@tab_live_view_token = token
|
|
705
|
+
@tab_live_view = Thread.new { run_tab_live_view(tab, token) }
|
|
658
706
|
@tab_live_view.report_on_exception = false
|
|
659
707
|
end
|
|
660
708
|
|
|
661
709
|
def stop_tab_live_view
|
|
662
|
-
|
|
663
|
-
@
|
|
664
|
-
|
|
710
|
+
thread = @tab_live_view
|
|
711
|
+
@tab_live_view_token = nil
|
|
712
|
+
thread&.join(0.2)
|
|
713
|
+
@tab_live_view = nil if @tab_live_view.equal?(thread)
|
|
665
714
|
end
|
|
666
715
|
|
|
667
|
-
def run_tab_live_view(tab)
|
|
716
|
+
def run_tab_live_view(tab, token)
|
|
668
717
|
renderer = tab_live_renderer(tab)
|
|
669
|
-
|
|
718
|
+
while @tab_live_view_token.equal?(token)
|
|
670
719
|
events = tab.event_history[tab.seen_events..] || []
|
|
671
|
-
events.each
|
|
672
|
-
|
|
720
|
+
events.each do |event|
|
|
721
|
+
break unless @tab_live_view_token.equal?(token)
|
|
722
|
+
|
|
723
|
+
renderer.call(event, tab.driver)
|
|
724
|
+
tab.seen_events += 1
|
|
725
|
+
end
|
|
726
|
+
break unless @tab_live_view_token.equal?(token)
|
|
727
|
+
|
|
673
728
|
if tab.idle?
|
|
674
729
|
renderer.call(:flush, tab.driver)
|
|
675
730
|
break
|
|
@@ -677,7 +732,8 @@ module Kward
|
|
|
677
732
|
sleep 0.05
|
|
678
733
|
end
|
|
679
734
|
ensure
|
|
680
|
-
@
|
|
735
|
+
@tab_live_view_token = nil if @tab_live_view_token.equal?(token)
|
|
736
|
+
@tab_live_view = nil if @tab_live_view == Thread.current
|
|
681
737
|
end
|
|
682
738
|
|
|
683
739
|
def tab_live_renderer(tab)
|
|
@@ -726,6 +782,8 @@ module Kward
|
|
|
726
782
|
def tab_labels
|
|
727
783
|
@tabs.each_with_index.map do |tab, index|
|
|
728
784
|
label = tab.label.to_s.empty? ? default_tab_label(index) : tab.label.to_s
|
|
785
|
+
binding = worktree_binding_for(tab)
|
|
786
|
+
label = "#{label} · #{binding.branch}" if binding&.active?
|
|
729
787
|
{ name: label, color: tab_label_color(tab) }
|
|
730
788
|
end
|
|
731
789
|
end
|
|
@@ -752,7 +810,7 @@ module Kward
|
|
|
752
810
|
action, value = argument.to_s.strip.split(/\s+/, 2)
|
|
753
811
|
case action
|
|
754
812
|
when nil, ""
|
|
755
|
-
runtime_output(
|
|
813
|
+
runtime_output(tab_usage)
|
|
756
814
|
nil
|
|
757
815
|
when /^\d+$/
|
|
758
816
|
switch_tab_number(action)
|
|
@@ -774,12 +832,19 @@ module Kward
|
|
|
774
832
|
when "name", "rename"
|
|
775
833
|
rename_active_tab(value)
|
|
776
834
|
active_tab&.agent
|
|
835
|
+
when "worktree"
|
|
836
|
+
handle_worktree_command(value)
|
|
837
|
+
active_tab&.agent
|
|
777
838
|
else
|
|
778
|
-
runtime_output(
|
|
839
|
+
runtime_output(tab_usage)
|
|
779
840
|
nil
|
|
780
841
|
end
|
|
781
842
|
end
|
|
782
843
|
|
|
844
|
+
def tab_usage
|
|
845
|
+
"/tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label> | /tab worktree [activate|detach|status|merge|merge abort|remove]"
|
|
846
|
+
end
|
|
847
|
+
|
|
783
848
|
def switch_tab_number(number)
|
|
784
849
|
index = number.to_i - 1
|
|
785
850
|
return switch_tab(index) if @tabs && index.between?(0, @tabs.length - 1)
|