kward 0.78.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +13 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +82 -1
  5. data/Gemfile.lock +2 -2
  6. data/README.md +33 -38
  7. data/doc/agent-tools.md +10 -11
  8. data/doc/api.md +3 -3
  9. data/doc/authentication.md +79 -110
  10. data/doc/code-search.md +1 -1
  11. data/doc/composer.md +1 -1
  12. data/doc/configuration.md +136 -27
  13. data/doc/context-budgeting.md +6 -6
  14. data/doc/context-tools.md +2 -2
  15. data/doc/editor.md +7 -7
  16. data/doc/extensibility.md +9 -29
  17. data/doc/files.md +6 -6
  18. data/doc/getting-started.md +3 -3
  19. data/doc/git.md +8 -2
  20. data/doc/lifecycle-hooks.md +5 -4
  21. data/doc/local-models.md +130 -0
  22. data/doc/mcp.md +5 -5
  23. data/doc/permissions.md +15 -5
  24. data/doc/plugins.md +39 -4
  25. data/doc/prompt-templates.md +105 -0
  26. data/doc/providers.md +140 -0
  27. data/doc/releasing.md +11 -3
  28. data/doc/rpc.md +65 -31
  29. data/doc/sandboxing.md +142 -0
  30. data/doc/security.md +31 -10
  31. data/doc/session-management.md +2 -4
  32. data/doc/skills.md +10 -0
  33. data/doc/tabs.md +37 -0
  34. data/doc/telegram.md +138 -0
  35. data/doc/transports.md +209 -0
  36. data/doc/usage.md +11 -3
  37. data/doc/web-search.md +3 -3
  38. data/doc/workspace-tools.md +4 -4
  39. data/examples/plugins/stardate_footer.rb +10 -0
  40. data/examples/plugins/telegram/plugin.rb +39 -0
  41. data/examples/plugins/telegram/telegram_api.rb +136 -0
  42. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  43. data/kward.gemspec +1 -1
  44. data/lib/kward/ansi.rb +1 -0
  45. data/lib/kward/auth/api_key_store.rb +99 -0
  46. data/lib/kward/cli/auth_commands.rb +72 -13
  47. data/lib/kward/cli/commands.rb +7 -0
  48. data/lib/kward/cli/doctor.rb +21 -0
  49. data/lib/kward/cli/git.rb +31 -8
  50. data/lib/kward/cli/plugins.rb +1 -0
  51. data/lib/kward/cli/prompt_interface.rb +7 -0
  52. data/lib/kward/cli/rendering.rb +6 -2
  53. data/lib/kward/cli/runtime_helpers.rb +4 -2
  54. data/lib/kward/cli/sessions.rb +1 -1
  55. data/lib/kward/cli/settings.rb +162 -36
  56. data/lib/kward/cli/slash_commands.rb +109 -1
  57. data/lib/kward/cli/tabs.rb +102 -37
  58. data/lib/kward/cli/transports.rb +67 -0
  59. data/lib/kward/cli/worktrees.rb +360 -0
  60. data/lib/kward/cli.rb +22 -1
  61. data/lib/kward/config_files.rb +106 -4
  62. data/lib/kward/conversation.rb +27 -8
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/local_command_runner.rb +2 -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 +364 -23
  68. data/lib/kward/model/model_info.rb +60 -1
  69. data/lib/kward/model/payloads.rb +64 -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 +141 -39
  73. data/lib/kward/pan/index.html.erb +50 -0
  74. data/lib/kward/pan/server.rb +49 -2
  75. data/lib/kward/permissions/policy.rb +18 -4
  76. data/lib/kward/plugin_chat_runtime.rb +374 -0
  77. data/lib/kward/plugin_registry.rb +81 -8
  78. data/lib/kward/private_file.rb +9 -3
  79. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  80. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  81. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  82. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  83. data/lib/kward/prompt_interface/editor/controller.rb +43 -10
  84. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  85. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  86. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  87. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +184 -9
  88. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  89. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  90. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  91. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  92. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  93. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  94. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  95. data/lib/kward/prompt_interface/screen.rb +3 -0
  96. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  97. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  98. data/lib/kward/prompt_interface.rb +71 -12
  99. data/lib/kward/prompts/commands.rb +3 -1
  100. data/lib/kward/prompts.rb +22 -10
  101. data/lib/kward/rpc/auth_manager.rb +112 -152
  102. data/lib/kward/rpc/config_manager.rb +46 -6
  103. data/lib/kward/rpc/plugin_chat_manager.rb +56 -194
  104. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  105. data/lib/kward/rpc/redactor.rb +1 -1
  106. data/lib/kward/rpc/server.rb +102 -14
  107. data/lib/kward/rpc/session_manager.rb +151 -38
  108. data/lib/kward/sandbox/capabilities.rb +39 -0
  109. data/lib/kward/sandbox/command_runner.rb +28 -0
  110. data/lib/kward/sandbox/environment.rb +24 -0
  111. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  112. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  113. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  114. data/lib/kward/sandbox/policy.rb +74 -0
  115. data/lib/kward/sandbox/runner_factory.rb +55 -0
  116. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  117. data/lib/kward/sandbox.rb +9 -0
  118. data/lib/kward/session_store.rb +110 -24
  119. data/lib/kward/skills/capture.rb +144 -0
  120. data/lib/kward/tab_driver.rb +6 -3
  121. data/lib/kward/tools/git_commit.rb +39 -0
  122. data/lib/kward/tools/registry.rb +26 -6
  123. data/lib/kward/tools/tool_call.rb +1 -0
  124. data/lib/kward/transport/gateway.rb +253 -0
  125. data/lib/kward/transport/host.rb +343 -0
  126. data/lib/kward/transport/manager.rb +179 -0
  127. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  128. data/lib/kward/transport/runtime.rb +41 -0
  129. data/lib/kward/transport/store.rb +101 -0
  130. data/lib/kward/transport.rb +204 -0
  131. data/lib/kward/version.rb +1 -1
  132. data/lib/kward/workspace.rb +18 -3
  133. data/lib/kward/workspace_factory.rb +34 -0
  134. data/templates/default/fulldoc/html/js/kward.js +3 -0
  135. data/templates/default/kward_navigation.rb +8 -1
  136. data/templates/default/layout/html/setup.rb +5 -0
  137. metadata +41 -2
@@ -15,6 +15,9 @@ module Kward
15
15
  when "stats"
16
16
  run_busy_local_command_and_requeue { print_stats(argument) }
17
17
  [true, nil]
18
+ when "sandbox"
19
+ run_busy_local_command_and_requeue { handle_sandbox_command(argument, interactive_workspace_root(agent)) }
20
+ [true, nil]
18
21
  when "memory"
19
22
  activity = memory_summarize_command?(argument) ? "summarizing" : "loading"
20
23
  run_busy_local_command_and_requeue(activity: activity) { handle_memory_command(argument, agent) }
@@ -23,7 +26,11 @@ module Kward
23
26
  run_busy_local_command_and_requeue { handle_hooks_command(argument) }
24
27
  [true, nil]
25
28
  when "skill"
26
- run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
29
+ if argument.to_s.strip == "capture"
30
+ capture_skill_from_session(session_store)
31
+ else
32
+ run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
33
+ end
27
34
  [true, nil]
28
35
  when "redraw"
29
36
  run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
@@ -114,6 +121,48 @@ module Kward
114
121
  PromptCommands.parse(command) || [nil, ""]
115
122
  end
116
123
 
124
+ def handle_sandbox_command(argument, workspace_root)
125
+ action, value = argument.to_s.strip.split(/\s+/, 2)
126
+ case action
127
+ when nil, "", "status"
128
+ print_sandbox_status(workspace_root)
129
+ when *Sandbox::Policy::MODES
130
+ ConfigFiles.update_nested_config("sandbox", { "mode" => action })
131
+ runtime_output("Command sandbox mode set to #{action}. New sessions and tabs use the updated policy.")
132
+ when "network"
133
+ network = value.to_s
134
+ unless Sandbox::Policy::NETWORK_MODES.include?(network)
135
+ runtime_output("Usage: /sandbox network allow|deny")
136
+ return
137
+ end
138
+
139
+ ConfigFiles.update_nested_config("sandbox", { "network" => network })
140
+ runtime_output("Command sandbox child network set to #{network}. New sessions and tabs use the updated policy.")
141
+ else
142
+ runtime_output("Usage: /sandbox [status|off|read_only|workspace_write|network allow|network deny]")
143
+ end
144
+ rescue ArgumentError => error
145
+ runtime_output("Sandbox configuration error: #{error.message}")
146
+ end
147
+
148
+ def print_sandbox_status(workspace_root)
149
+ config = ConfigFiles.read_config
150
+ policy = ConfigFiles.sandbox_policy(workspace_root, config)
151
+ runner = Sandbox::RunnerFactory.build(policy)
152
+ capabilities = runner.capabilities
153
+ lines = [
154
+ "Command sandbox",
155
+ "Mode: #{policy.mode}",
156
+ "Child network: #{policy.network}",
157
+ "Backend: #{capabilities.backend}",
158
+ "Filesystem enforcement: #{capabilities.filesystem_enforced? ? "active" : "inactive"}",
159
+ "Child-network enforcement: #{capabilities.child_network_enforced? ? "active" : "inactive"}",
160
+ "Scope: model-requested run_shell_command workers only"
161
+ ]
162
+ lines << "Reason: #{capabilities.reason}" unless capabilities.reason.to_s.empty?
163
+ runtime_output(lines.join("\n"))
164
+ end
165
+
117
166
  def open_or_resume_session(session_store, argument)
118
167
  unless session_store
119
168
  say_sessions_unavailable
@@ -144,6 +193,65 @@ module Kward
144
193
  end
145
194
  end
146
195
 
196
+ def capture_skill_from_session(session_store)
197
+ unless session_store
198
+ say_sessions_unavailable
199
+ return
200
+ end
201
+ unless @prompt.respond_to?(:review_document)
202
+ runtime_output("Skill capture is available only in the interactive terminal prompt.")
203
+ return
204
+ end
205
+
206
+ source_path = select_skill_capture_session(session_store)
207
+ return unless source_path
208
+
209
+ capture = Skills::Capture.new(session_store: session_store, client: @client)
210
+ draft = run_busy_local_command_and_requeue(activity: "capturing skill") { capture.generate(source_path) }
211
+ review_captured_skill(capture, draft)
212
+ rescue Skills::Capture::Error => error
213
+ runtime_output("Skill capture failed: #{error.message}")
214
+ end
215
+
216
+ def select_skill_capture_session(session_store)
217
+ sessions = session_store.capture_candidates
218
+ if sessions.empty?
219
+ runtime_output("No saved sessions found.")
220
+ return nil
221
+ end
222
+
223
+ labels = session_picker_labels(sessions)
224
+ if @prompt.respond_to?(:select)
225
+ choice = @prompt.select("Capture session>", labels, title: "Capture skill from session")
226
+ return sessions[labels.index(choice)]&.path
227
+ end
228
+
229
+ runtime_output((["Saved sessions:"] + labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }).join("\n"))
230
+ answer = @prompt.ask("Session number or path>").to_s.strip
231
+ answer.match?(/\A\d+\z/) ? sessions[answer.to_i - 1]&.path : answer
232
+ end
233
+
234
+ def review_captured_skill(capture, draft)
235
+ overwrite = false
236
+ saved = nil
237
+ @prompt.review_document(title: "Review captured skill", content: draft.content) do |content|
238
+ begin
239
+ saved = capture.save(content, overwrite: overwrite)
240
+ nil
241
+ rescue Skills::Capture::ConflictError
242
+ if overwrite
243
+ "Could not overwrite the existing skill. Review the name and try again."
244
+ else
245
+ overwrite = true
246
+ "A personal skill with this name exists. Press Ctrl+S again to overwrite it."
247
+ end
248
+ rescue Skills::Capture::Error => error
249
+ error.message
250
+ end
251
+ end
252
+ runtime_output("Saved personal skill: #{capture.skill_path(saved.name)}") if saved
253
+ end
254
+
147
255
  def activate_skill_command(name, agent)
148
256
  skill_name = name.to_s.strip
149
257
  if skill_name.empty?
@@ -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)
@@ -0,0 +1,67 @@
1
+ require_relative "../transport/runtime"
2
+
3
+ module Kward
4
+ class CLI
5
+ # Foreground transport lifecycle commands.
6
+ module Transports
7
+ private
8
+
9
+ def handle_transport_command(arguments)
10
+ command = arguments.first.to_s
11
+ case command
12
+ when "list"
13
+ raise ArgumentError, command_usage("transport") unless arguments.length == 1
14
+
15
+ with_transport_runtime { |runtime| print_transport_entries(runtime.manager.list) }
16
+ when "status"
17
+ raise ArgumentError, command_usage("transport") unless arguments.length <= 2
18
+
19
+ with_transport_runtime do |runtime|
20
+ entries = arguments.length == 2 ? [runtime.manager.status(arguments[1])] : runtime.manager.status
21
+ print_transport_entries(entries)
22
+ end
23
+ when "run"
24
+ raise ArgumentError, command_usage("transport") unless (2..3).cover?(arguments.length)
25
+
26
+ workspace_root = arguments[2] ? expanded_working_directory(arguments[2]) : @working_directory
27
+ run_transport(arguments[1], workspace_root: workspace_root)
28
+ else
29
+ raise ArgumentError, command_usage("transport")
30
+ end
31
+ end
32
+
33
+ def print_transport_entries(entries)
34
+ entries.each do |entry|
35
+ @prompt.say("#{entry[:id]}\t#{entry[:state] || "stopped"}")
36
+ end
37
+ end
38
+
39
+ def run_transport(name, workspace_root: nil)
40
+ runtime = Transport::Runtime.new(client: ensure_client!)
41
+ runtime.manager.start(name, workspace_root: workspace_root)
42
+ @prompt.say("Transport #{name} is running. Press Ctrl-C to stop.")
43
+ wait_for_transport_shutdown
44
+ ensure
45
+ runtime&.shutdown
46
+ end
47
+
48
+ def with_transport_runtime
49
+ runtime = Transport::Runtime.new(client: ensure_client!)
50
+ yield runtime
51
+ ensure
52
+ runtime&.shutdown
53
+ end
54
+
55
+ def wait_for_transport_shutdown
56
+ stopped = false
57
+ previous_handlers = {}
58
+ %w[INT TERM].each do |signal|
59
+ previous_handlers[signal] = Signal.trap(signal) { stopped = true }
60
+ end
61
+ sleep 0.1 until stopped
62
+ ensure
63
+ previous_handlers&.each { |signal, handler| Signal.trap(signal, handler) }
64
+ end
65
+ end
66
+ end
67
+ end