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.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +58 -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/tabs.md +37 -0
  32. data/doc/telegram.md +138 -0
  33. data/doc/transports.md +209 -0
  34. data/doc/usage.md +11 -3
  35. data/doc/web-search.md +3 -3
  36. data/doc/workspace-tools.md +2 -2
  37. data/examples/plugins/stardate_footer.rb +10 -0
  38. data/examples/plugins/telegram/plugin.rb +39 -0
  39. data/examples/plugins/telegram/telegram_api.rb +136 -0
  40. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  41. data/lib/kward/ansi.rb +1 -0
  42. data/lib/kward/auth/api_key_store.rb +99 -0
  43. data/lib/kward/cli/auth_commands.rb +72 -13
  44. data/lib/kward/cli/commands.rb +7 -0
  45. data/lib/kward/cli/git.rb +31 -8
  46. data/lib/kward/cli/plugins.rb +1 -0
  47. data/lib/kward/cli/prompt_interface.rb +7 -0
  48. data/lib/kward/cli/rendering.rb +6 -2
  49. data/lib/kward/cli/runtime_helpers.rb +4 -2
  50. data/lib/kward/cli/sessions.rb +1 -1
  51. data/lib/kward/cli/settings.rb +97 -33
  52. data/lib/kward/cli/tabs.rb +102 -37
  53. data/lib/kward/cli/transports.rb +67 -0
  54. data/lib/kward/cli/worktrees.rb +360 -0
  55. data/lib/kward/cli.rb +20 -0
  56. data/lib/kward/config_files.rb +26 -3
  57. data/lib/kward/conversation.rb +13 -7
  58. data/lib/kward/git_worktree_manager.rb +291 -0
  59. data/lib/kward/model/azure_openai_config.rb +54 -0
  60. data/lib/kward/model/catalog.rb +113 -0
  61. data/lib/kward/model/client.rb +225 -20
  62. data/lib/kward/model/model_info.rb +49 -0
  63. data/lib/kward/model/payloads.rb +63 -4
  64. data/lib/kward/model/provider_catalog.rb +114 -0
  65. data/lib/kward/model/sources.rb +70 -0
  66. data/lib/kward/model/stream_parser.rb +83 -13
  67. data/lib/kward/permissions/policy.rb +18 -4
  68. data/lib/kward/plugin_chat_runtime.rb +374 -0
  69. data/lib/kward/plugin_registry.rb +80 -7
  70. data/lib/kward/private_file.rb +9 -3
  71. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  72. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  73. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  74. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  75. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  76. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  77. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  78. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  79. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  80. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  81. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  82. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  83. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  84. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  85. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  86. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  87. data/lib/kward/prompt_interface/screen.rb +3 -0
  88. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  89. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  90. data/lib/kward/prompt_interface.rb +53 -12
  91. data/lib/kward/prompts/commands.rb +1 -1
  92. data/lib/kward/prompts.rb +6 -5
  93. data/lib/kward/rpc/auth_manager.rb +112 -152
  94. data/lib/kward/rpc/config_manager.rb +46 -6
  95. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  96. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  97. data/lib/kward/rpc/redactor.rb +1 -1
  98. data/lib/kward/rpc/server.rb +60 -10
  99. data/lib/kward/rpc/session_manager.rb +109 -30
  100. data/lib/kward/session_store.rb +84 -24
  101. data/lib/kward/tab_driver.rb +6 -3
  102. data/lib/kward/tools/git_commit.rb +39 -0
  103. data/lib/kward/tools/registry.rb +7 -2
  104. data/lib/kward/tools/tool_call.rb +1 -0
  105. data/lib/kward/transport/gateway.rb +253 -0
  106. data/lib/kward/transport/host.rb +343 -0
  107. data/lib/kward/transport/manager.rb +179 -0
  108. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  109. data/lib/kward/transport/runtime.rb +41 -0
  110. data/lib/kward/transport/store.rb +101 -0
  111. data/lib/kward/transport.rb +204 -0
  112. data/lib/kward/version.rb +1 -1
  113. data/lib/kward/workspace_factory.rb +19 -2
  114. data/templates/default/fulldoc/html/js/kward.js +2 -0
  115. data/templates/default/kward_navigation.rb +7 -2
  116. data/templates/default/layout/html/setup.rb +4 -0
  117. metadata +26 -1
@@ -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 }
@@ -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)
@@ -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
@@ -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
 
@@ -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
@@ -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 |_descriptor, index|
124
- tab = build_tab_from_descriptor(data.fetch("tabs")[index], session_store, label: restored_tab_label(data, index))
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
- next
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
- session, conversation = restore_tab_session(session_store, descriptor.fetch("session_path"))
140
- agent = build_tab_agent(conversation, session)
141
- return build_tab(session, agent, driver: SessionTabDriver.new(session: session, agent: agent), label: label)
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: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
197
+ session, conversation = session_store.load(path, workspace: configured_workspace(root: workspace_root, strict: strict), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
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: session_store.cwd)
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
- workspace = configured_workspace(root: conversation.workspace_root)
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
- tab.agent = build_tab_agent(agent.conversation, tab.session)
376
- tab.driver = SessionTabDriver.new(session: tab.session, agent: tab.agent)
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 = @prompt.tab_view_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
- if tab.thread && !tab.thread.alive? && tab.running?
646
- tab.thread.join
647
- tab.status = "ready" if tab.status == "running"
648
- end
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
- @tab_live_view_stop = false
657
- @tab_live_view = Thread.new { run_tab_live_view(tab) }
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
- @tab_live_view_stop = true
663
- @tab_live_view&.join(0.2)
664
- @tab_live_view = nil
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
- until @tab_live_view_stop
718
+ while @tab_live_view_token.equal?(token)
670
719
  events = tab.event_history[tab.seen_events..] || []
671
- events.each { |event| renderer.call(event, tab.driver) }
672
- tab.seen_events += events.length
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
- @tab_live_view_stop = false if @tab_live_view == Thread.current
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("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label>")
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("Usage: /tab 1-n | /tab move 1-n|left|right | /tab close | /tab new | /tab open <plugin-tab> | /tab name <label>")
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)