kward 0.83.0 → 0.85.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 (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +13 -1
  9. data/doc/api.md +21 -2
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +95 -25
  12. data/doc/editor.md +28 -13
  13. data/doc/extensibility.md +2 -1
  14. data/doc/files.md +8 -4
  15. data/doc/getting-started.md +3 -0
  16. data/doc/git.md +3 -1
  17. data/doc/pan.md +25 -15
  18. data/doc/permissions.md +4 -4
  19. data/doc/platform-support.md +48 -0
  20. data/doc/plugins.md +464 -15
  21. data/doc/rpc.md +154 -16
  22. data/doc/sandboxing.md +11 -5
  23. data/doc/security.md +10 -3
  24. data/doc/session-management.md +5 -4
  25. data/doc/shell.md +62 -45
  26. data/doc/tabs.md +6 -2
  27. data/doc/transports.md +15 -0
  28. data/doc/troubleshooting.md +12 -2
  29. data/doc/usage.md +9 -6
  30. data/doc/workspace-tools.md +9 -0
  31. data/examples/plugins/space_invaders.rb +1 -1
  32. data/examples/plugins/stardate_footer.rb +2 -2
  33. data/examples/plugins/telegram/plugin.rb +1 -1
  34. data/kward.gemspec +5 -4
  35. data/lib/kward/agent.rb +30 -14
  36. data/lib/kward/cli/auth_commands.rb +34 -13
  37. data/lib/kward/cli/commands.rb +83 -62
  38. data/lib/kward/cli/compaction.rb +9 -3
  39. data/lib/kward/cli/doctor.rb +39 -17
  40. data/lib/kward/cli/hook_commands.rb +22 -12
  41. data/lib/kward/cli/interactive_turn.rb +48 -7
  42. data/lib/kward/cli/plugins.rb +81 -12
  43. data/lib/kward/cli/project_skills_commands.rb +8 -4
  44. data/lib/kward/cli/prompt_interface.rb +52 -5
  45. data/lib/kward/cli/rendering.rb +18 -9
  46. data/lib/kward/cli/runtime_helpers.rb +228 -71
  47. data/lib/kward/cli/sessions.rb +9 -5
  48. data/lib/kward/cli/settings/menus.rb +745 -0
  49. data/lib/kward/cli/settings/model.rb +327 -0
  50. data/lib/kward/cli/settings.rb +6 -1055
  51. data/lib/kward/cli/slash_commands.rb +56 -17
  52. data/lib/kward/cli/tabs.rb +244 -33
  53. data/lib/kward/cli/tool_summaries.rb +14 -0
  54. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
  55. data/lib/kward/cli/worktrees.rb +65 -2
  56. data/lib/kward/cli.rb +70 -30
  57. data/lib/kward/compactor.rb +18 -7
  58. data/lib/kward/config/core.rb +389 -0
  59. data/lib/kward/config/extensions.rb +96 -0
  60. data/lib/kward/config/prompts.rb +313 -0
  61. data/lib/kward/config/settings.rb +250 -0
  62. data/lib/kward/config_files.rb +14 -994
  63. data/lib/kward/conversation.rb +31 -2
  64. data/lib/kward/image_attachments.rb +1 -1
  65. data/lib/kward/model/client.rb +36 -24
  66. data/lib/kward/model/copilot_models.rb +2 -2
  67. data/lib/kward/model/model_info.rb +20 -3
  68. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  69. data/lib/kward/model/payloads.rb +12 -3
  70. data/lib/kward/model/provider_catalog.rb +5 -0
  71. data/lib/kward/model/stream_parser.rb +20 -4
  72. data/lib/kward/model/typesafe_client.rb +78 -0
  73. data/lib/kward/pan/index.html.erb +3 -3
  74. data/lib/kward/pan/server.rb +33 -10
  75. data/lib/kward/permissions/policy.rb +6 -2
  76. data/lib/kward/plugin_registry.rb +2 -659
  77. data/lib/kward/plugins/actions.rb +453 -0
  78. data/lib/kward/plugins/chat_contract.rb +121 -0
  79. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  80. data/lib/kward/plugins/host.rb +232 -0
  81. data/lib/kward/plugins/registry.rb +1190 -0
  82. data/lib/kward/plugins/resources.rb +206 -0
  83. data/lib/kward/plugins/turn_request.rb +36 -0
  84. data/lib/kward/plugins/ui.rb +219 -0
  85. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  86. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  87. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  88. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  89. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  90. data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
  91. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  92. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  93. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  94. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  95. data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
  96. data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
  97. data/lib/kward/prompt_interface/editor/state.rb +11 -11
  98. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  99. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  100. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  101. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  102. data/lib/kward/prompt_interface/layout.rb +1 -1
  103. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  104. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  105. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  106. data/lib/kward/prompt_interface/runtime_state.rb +56 -2
  107. data/lib/kward/prompt_interface/screen.rb +11 -4
  108. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  109. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  110. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  111. data/lib/kward/prompt_interface.rb +151 -27
  112. data/lib/kward/prompts/commands.rb +3 -2
  113. data/lib/kward/prompts.rb +1 -1
  114. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  115. data/lib/kward/pty/detached_run.rb +44 -0
  116. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
  117. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  118. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  119. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
  120. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  121. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  122. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  123. data/lib/kward/rpc/server.rb +91 -12
  124. data/lib/kward/rpc/session_manager.rb +147 -44
  125. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  126. data/lib/kward/rpc/tool_metadata.rb +1 -1
  127. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  128. data/lib/kward/sandbox/command_runner.rb +1 -1
  129. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  130. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  131. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  132. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  133. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  134. data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
  135. data/lib/kward/shell/kwshrc.rb +233 -0
  136. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
  137. data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
  138. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  139. data/lib/kward/skills/trust_store.rb +1 -1
  140. data/lib/kward/tabs/driver.rb +194 -0
  141. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  142. data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
  143. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  144. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  145. data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
  146. data/lib/kward/terminal/text.rb +121 -0
  147. data/lib/kward/text_matcher.rb +18 -0
  148. data/lib/kward/tools/base.rb +18 -0
  149. data/lib/kward/tools/context_for_task.rb +15 -6
  150. data/lib/kward/tools/edit_file.rb +9 -6
  151. data/lib/kward/tools/git_commit.rb +13 -7
  152. data/lib/kward/tools/list_directory.rb +4 -4
  153. data/lib/kward/tools/open_editor.rb +41 -0
  154. data/lib/kward/tools/plugin_tool.rb +41 -0
  155. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  156. data/lib/kward/tools/read_file.rb +7 -6
  157. data/lib/kward/tools/registry.rb +109 -17
  158. data/lib/kward/tools/run_shell_command.rb +10 -8
  159. data/lib/kward/tools/search/code.rb +1 -1
  160. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  161. data/lib/kward/tools/tool_call.rb +3 -1
  162. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  163. data/lib/kward/tools/workspace_targets.rb +58 -0
  164. data/lib/kward/tools/write_file.rb +9 -6
  165. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  166. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  167. data/lib/kward/transport/contracts.rb +200 -0
  168. data/lib/kward/transport/gateway.rb +79 -35
  169. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  170. data/lib/kward/transport.rb +1 -200
  171. data/lib/kward/version.rb +1 -1
  172. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  173. data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
  174. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  175. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  176. data/templates/default/kward_navigation.rb +1 -0
  177. data/templates/default/layout/html/footer.erb +10 -0
  178. data/templates/default/layout/html/headers.erb +23 -0
  179. data/templates/default/layout/html/layout.erb +2 -2
  180. data/templates/default/layout/html/setup.rb +41 -2
  181. metadata +94 -47
  182. data/lib/kward/scratchpad_runner.rb +0 -56
  183. data/lib/kward/tab_driver.rb +0 -90
  184. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  185. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  186. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  187. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  188. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  189. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  190. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  191. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  192. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  193. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -1,3 +1,5 @@
1
+ require_relative "../prompt_interface/editor/scratchpad_languages"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
@@ -48,7 +50,7 @@ module Kward
48
50
  open_project_files_browser
49
51
  [true, nil]
50
52
  when "shell"
51
- run_ekwsh(agent)
53
+ run_kwsh(agent)
52
54
  [true, nil]
53
55
  when "capture"
54
56
  run_captured_shell_command(argument, agent)
@@ -62,6 +64,9 @@ module Kward
62
64
  [true, nil]
63
65
  when "tab"
64
66
  [true, handle_tab_command(argument, session_store)]
67
+ when "name"
68
+ rename_active_session_and_tab(argument)
69
+ [true, nil]
65
70
  when "worktree"
66
71
  handle_worktree_command(argument)
67
72
  [true, active_tab&.agent]
@@ -79,7 +84,9 @@ module Kward
79
84
  configure_reasoning(agent.conversation)
80
85
  [true, nil]
81
86
  when "reload"
82
- run_busy_local_command_and_requeue { reload_plugins(agent.conversation) }
87
+ strict_worktree = active_tab&.driver&.respond_to?(:worktree) && active_tab.driver.worktree&.active?
88
+ registry = strict_worktree ? nil : agent.tool_registry
89
+ run_busy_local_command_and_requeue { reload_plugins(agent.conversation, tool_registry: registry) }
83
90
  [true, nil]
84
91
  when "new"
85
92
  [true, run_busy_local_command_and_requeue { start_new_session(session_store) }]
@@ -111,7 +118,11 @@ module Kward
111
118
  run_busy_local_command_and_requeue { export_session(agent.conversation, argument) }
112
119
  [true, nil]
113
120
  when "compact"
114
- run_busy_local_command_and_requeue(activity: "compacting") { compact_context(agent, argument) }
121
+ origin_tab = active_tab
122
+ result = run_busy_local_command_and_requeue(activity: "compacting") do |cancellation|
123
+ compact_context(agent, argument, cancellation: cancellation)
124
+ end
125
+ render_compaction_summary(result) if !origin_tab || origin_tab == active_tab
115
126
  [true, nil]
116
127
  else
117
128
  if name.to_s.start_with?("skill:")
@@ -120,7 +131,7 @@ module Kward
120
131
  elsif interactive_command_for(name) && prompt_interface? && @prompt.respond_to?(:start_interactive)
121
132
  run_interactive_command(name, argument, agent)
122
133
  elsif plugin_command_for(name)
123
- run_busy_local_command_and_requeue(activity: "running") { run_plugin_command(name, argument, agent) }
134
+ run_plugin_command_and_turn(name, argument, agent)
124
135
  else
125
136
  [false, nil]
126
137
  end
@@ -131,6 +142,38 @@ module Kward
131
142
  PromptCommands.parse(command) || [nil, ""]
132
143
  end
133
144
 
145
+ def rename_active_session_and_tab(argument)
146
+ name = argument.to_s.strip
147
+ return runtime_output("Usage: /name <name>") if name.empty?
148
+
149
+ tab = active_tab
150
+ unless @active_session && (!tab || tab.session.equal?(@active_session))
151
+ return runtime_output("The /name command is only available in a session tab.")
152
+ end
153
+
154
+ errors = []
155
+ begin
156
+ rename_session(name, require_name: true, announce: false)
157
+ rescue StandardError => error
158
+ errors << "session: #{error.message}"
159
+ end
160
+
161
+ if tab
162
+ begin
163
+ rename_active_tab(name)
164
+ rescue StandardError => error
165
+ errors << "tab: #{error.message}"
166
+ end
167
+ end
168
+
169
+ if errors.empty?
170
+ label = tab ? "Named session and tab: #{name}" : "Named session: #{name}"
171
+ runtime_output(label)
172
+ else
173
+ runtime_output("Name update incomplete (#{errors.join("; ")}).")
174
+ end
175
+ end
176
+
134
177
  def handle_sandbox_command(argument, workspace_root)
135
178
  action, value = argument.to_s.strip.split(/\s+/, 2)
136
179
  case action
@@ -304,11 +347,16 @@ module Kward
304
347
  end
305
348
 
306
349
  def open_scratchpad_command(argument)
307
- if @prompt.respond_to?(:scratchpad)
308
- @prompt.scratchpad(scratchpad_language_argument(argument))
309
- else
310
- runtime_output("The scratchpad is only available in the interactive prompt.")
350
+ unless @prompt.respond_to?(:scratchpad)
351
+ return runtime_output("The scratchpad is only available in the interactive prompt.")
311
352
  end
353
+
354
+ value = argument.to_s.strip
355
+ return runtime_output(ScratchpadLanguages.help_text) if value.casecmp("help").zero?
356
+
357
+ @prompt.scratchpad(ScratchpadLanguages.normalize(value))
358
+ rescue ArgumentError => e
359
+ runtime_output(e.message)
312
360
  end
313
361
 
314
362
  def queue_editor_prompt_action(result)
@@ -318,15 +366,6 @@ module Kward
318
366
  @pending_inputs.unshift(result)
319
367
  end
320
368
 
321
- def scratchpad_language_argument(argument)
322
- value = argument.to_s.strip.downcase
323
- return :text if value.empty? || value == "text"
324
- return :markdown if ["markdown", "md"].include?(value)
325
- return :ruby if ["ruby", "rb"].include?(value)
326
-
327
- :text
328
- end
329
-
330
369
  def open_project_files_browser
331
370
  if @prompt.respond_to?(:open_project_browser)
332
371
  @prompt.open_project_browser
@@ -1,4 +1,5 @@
1
1
  require "json"
2
+ require "securerandom"
2
3
  require "thread"
3
4
  require_relative "../cancellation"
4
5
 
@@ -10,14 +11,16 @@ module Kward
10
11
  module Tabs
11
12
  WORKTREE_AGENT_CONTEXT = <<~PROMPT.strip.freeze
12
13
  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.
14
+ Workspace tools default to the active worktree. When they advertise `target`, use `target: "origin"` to inspect the verified host repository worktree without leaving this tab. Read the origin's applicable `AGENTS.md` before changing it.
15
+ When the user explicitly asks you to commit, use the advertised `git_commit` tool. Set `target: "origin"` only when the user asks to commit there. Do not use `run_shell_command` for `git add` or `git commit`, because shared Git metadata is intentionally protected.
16
+ `git_commit` requires a commit message and accepts optional target-relative paths; omit paths to include all current changes in the selected worktree. Do not create a commit unless the user requested one.
15
17
  PROMPT
16
18
 
17
19
  TabRuntime = Struct.new(
18
20
  :session,
19
21
  :agent,
20
22
  :driver,
23
+ :plugin_host,
21
24
  :diff,
22
25
  :snapshot,
23
26
  :status,
@@ -33,12 +36,15 @@ module Kward
33
36
  :markdown_chunks,
34
37
  :label,
35
38
  :unread,
39
+ :attention,
36
40
  :pending_question,
37
41
  :shell,
38
42
  :shell_agent,
43
+ :background_run,
39
44
  :transient_shell_entries,
40
45
  :error_reported,
41
46
  :local_busy_activity,
47
+ :pending_runtime_outputs,
42
48
  keyword_init: true
43
49
  ) do
44
50
  TRANSIENT_SHELL_OUTPUT_LIMIT = 200_000
@@ -51,6 +57,11 @@ module Kward
51
57
  !local_busy_activity.to_s.empty?
52
58
  end
53
59
 
60
+ def append_pending_runtime_output(text)
61
+ self.pending_runtime_outputs ||= []
62
+ pending_runtime_outputs << text.to_s
63
+ end
64
+
54
65
  def idle?
55
66
  !running? && !local_busy?
56
67
  end
@@ -106,6 +117,10 @@ module Kward
106
117
  @cli.send(:ask_tab_user_question, @tab, questions, cancellation: cancellation)
107
118
  end
108
119
 
120
+ def edit_file(path, base_dir:, allow_new:)
121
+ @cli.send(:open_editor_for_agent, path, base_dir: base_dir, allow_new: allow_new)
122
+ end
123
+
109
124
  def ask_tool_approval(tool_name:, args:, reason: nil)
110
125
  answers = ask_user_question([
111
126
  {
@@ -199,14 +214,23 @@ module Kward
199
214
  end
200
215
 
201
216
  tab_type = plugin_registry.tab_type_for_id(descriptor["plugin_tab_type"])
217
+ host = nil
202
218
  driver = if tab_type&.local
203
- host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
204
- tab_type.handler.call(host, descriptor)
219
+ descriptor["scope_key"] ||= plugin_tab_scope_key(tab_type)
220
+ host = build_plugin_tab_host(tab_type, workspace_root: session_store.cwd, scope_key: descriptor["scope_key"])
221
+ begin
222
+ created_driver = tab_type.handler.call(host, descriptor)
223
+ raise "Plugin tab #{descriptor["plugin_tab_type"].inspect} did not return a tab driver." unless created_driver
224
+
225
+ tab_type.capabilities.validate_driver!(created_driver)
226
+ rescue StandardError
227
+ host.shutdown
228
+ raise
229
+ end
205
230
  else
206
231
  UnavailableTabDriver.new(descriptor: descriptor, message: "Plugin tab #{descriptor["plugin_tab_type"].inspect} is unavailable.")
207
232
  end
208
- raise "Plugin tab #{descriptor["plugin_tab_type"].inspect} did not return a tab driver." unless driver
209
- build_tab(nil, nil, driver: driver, label: label || descriptor["label"] || tab_type&.title)
233
+ build_tab(nil, nil, driver: driver, plugin_host: host, label: label || descriptor["label"] || tab_type&.title)
210
234
  end
211
235
 
212
236
  def open_plugin_tab(name, session_store)
@@ -219,12 +243,20 @@ module Kward
219
243
 
220
244
  save_active_tab_state
221
245
  stop_tab_live_view
222
- descriptor = { "kind" => "plugin", "plugin_tab_type" => tab_type.id, "label" => tab_type.title }
223
- host = PluginTabHost.new(client: @client, workspace_root: session_store.cwd)
224
- driver = tab_type.handler.call(host, descriptor)
225
- raise "Plugin tab #{name.inspect} did not return a tab driver." unless driver
246
+ scope_key = plugin_tab_scope_key(tab_type)
247
+ descriptor = { "kind" => "plugin", "plugin_tab_type" => tab_type.id, "label" => tab_type.title, "scope_key" => scope_key }
248
+ host = build_plugin_tab_host(tab_type, workspace_root: session_store.cwd, scope_key: scope_key)
249
+ begin
250
+ driver = tab_type.handler.call(host, descriptor)
251
+ raise "Plugin tab #{name.inspect} did not return a tab driver." unless driver
252
+
253
+ tab_type.capabilities.validate_driver!(driver)
254
+ rescue StandardError
255
+ host.shutdown
256
+ raise
257
+ end
226
258
 
227
- @tabs << build_tab(nil, nil, driver: driver, label: tab_type.title)
259
+ @tabs << build_tab(nil, nil, driver: driver, plugin_host: host, label: tab_type.title)
228
260
  @active_tab_index = @tabs.length - 1
229
261
  activate_tab(@active_tab_index)
230
262
  rescue StandardError => e
@@ -248,21 +280,30 @@ module Kward
248
280
  strict = worktree&.active? == true
249
281
  update_worktree_agent_context(conversation, strict)
250
282
  workspace = configured_workspace(root: conversation.workspace_root, strict: strict)
283
+ workspace_targets = if strict
284
+ origin_workspace = configured_workspace(root: worktree.origin_root, strict: true)
285
+ Tools::WorkspaceTargets.new(active: workspace, targets: { origin: origin_workspace })
286
+ end
251
287
  prompt = TabQuestionPrompt.new(self)
252
288
  hook_manager = strict ? nil : lifecycle_hook_manager(conversation)
253
289
  hook_context = strict ? nil : lifecycle_hook_context(conversation)
254
290
  git_committer = if strict
255
- ->(message:, paths:) { git_commit_for_agent(workspace.root.to_s, message: message, paths: paths) }
291
+ lambda do |message:, paths:, target: Tools::WorkspaceTargets::ACTIVE|
292
+ commit_workspace = target == "origin" ? worktree.origin_root : workspace.root.to_s
293
+ git_commit_for_agent(commit_workspace, message: message, paths: paths)
294
+ end
256
295
  end
257
296
  project_skill_paths = project_skill_paths_for(conversation.workspace_root) || []
258
297
  tool_registry = ToolRegistry.new(
259
298
  workspace: workspace,
299
+ workspace_targets: workspace_targets,
260
300
  prompt: prompt,
261
301
  skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths),
262
302
  tool_approval: tab_tool_approval_callback(prompt),
263
303
  hook_manager: hook_manager,
264
304
  hook_context: hook_context,
265
305
  mcp_clients: strict ? [] : nil,
306
+ plugin_tools: strict ? [] : plugin_registry.tools,
266
307
  git_committer: git_committer
267
308
  )
268
309
  @footer_conversation = conversation
@@ -295,12 +336,13 @@ module Kward
295
336
  end
296
337
  end
297
338
 
298
- def build_tab(session, agent, driver: nil, label: nil)
339
+ def build_tab(session, agent, driver: nil, plugin_host: nil, label: nil)
299
340
  driver ||= SessionTabDriver.new(session: session, agent: agent)
300
341
  TabRuntime.new(
301
342
  session: session,
302
343
  agent: agent,
303
344
  driver: driver,
345
+ plugin_host: plugin_host,
304
346
  diff: session&.path ? SessionDiff.from_session_file(session.path) : SessionDiff.new,
305
347
  snapshot: nil,
306
348
  status: "idle",
@@ -316,12 +358,15 @@ module Kward
316
358
  markdown_chunks: [],
317
359
  label: label,
318
360
  unread: false,
361
+ attention: nil,
319
362
  pending_question: nil,
320
363
  shell: nil,
321
364
  shell_agent: nil,
365
+ background_run: nil,
322
366
  transient_shell_entries: [],
323
367
  error_reported: false,
324
- local_busy_activity: nil
368
+ local_busy_activity: nil,
369
+ pending_runtime_outputs: []
325
370
  ).tap { |tab| assign_tab_question_prompt(agent, tab) }
326
371
  end
327
372
 
@@ -420,16 +465,17 @@ module Kward
420
465
  end
421
466
 
422
467
  if @tabs.length <= 1
423
- close_ekwsh_session(tab.shell) if tab&.shell && respond_to?(:close_ekwsh_session, true)
468
+ close_kwsh_session(tab.shell) if tab&.shell && respond_to?(:close_kwsh_session, true)
469
+ close_plugin_tab(tab)
424
470
  @tabs.clear
425
471
  persist_tabs
426
472
  return PromptInterface::EXIT_INPUT
427
473
  end
428
474
 
429
475
  stop_tab_live_view
430
- close_ekwsh_session(tab.shell) if tab&.shell && respond_to?(:close_ekwsh_session, true)
476
+ close_kwsh_session(tab.shell) if tab&.shell && respond_to?(:close_kwsh_session, true)
431
477
  tab.session&.delete_if_unused if tab&.session.respond_to?(:delete_if_unused)
432
- tab.driver.close if tab.driver.respond_to?(:close)
478
+ close_plugin_tab(tab)
433
479
  @tabs.delete_at(@active_tab_index)
434
480
  @active_tab_index = [@active_tab_index, @tabs.length - 1].min
435
481
  activate_tab(@active_tab_index)
@@ -461,12 +507,13 @@ module Kward
461
507
  tab.error = nil
462
508
  tab.answer = nil
463
509
  tab.unread = false
510
+ tab.attention = nil
464
511
  tab.error_reported = false
465
512
  tab.event_history.clear
466
513
  tab.seen_events = 0
467
514
  tab.queued_inputs.clear
468
515
  tab.steering = nil
469
- close_ekwsh_session(tab.shell) if tab.shell && respond_to?(:close_ekwsh_session, true)
516
+ close_kwsh_session(tab.shell) if tab.shell && respond_to?(:close_kwsh_session, true)
470
517
  tab.shell = nil
471
518
  tab.shell_agent = nil
472
519
  tab.clear_transient_shell_entries
@@ -501,6 +548,7 @@ module Kward
501
548
  end
502
549
 
503
550
  def activate_tab(index, render: true)
551
+ service_background_tab_runs
504
552
  tab = @tabs[index]
505
553
  return nil unless tab
506
554
 
@@ -517,6 +565,7 @@ module Kward
517
565
  @prompt.update_assistant_label(assistant_prompt_name) if @prompt.respond_to?(:update_assistant_label)
518
566
  end
519
567
  tab.unread = false
568
+ tab.attention = nil
520
569
  restore_tab_composer_snapshot(tab.snapshot)
521
570
  update_prompt_workspace_root(current_workspace_root)
522
571
  @prompt.refresh_composer_status if @prompt.respond_to?(:refresh_composer_status)
@@ -544,8 +593,23 @@ module Kward
544
593
  end
545
594
  report_tab_runtime_error(tab) if %w[failed cancelled].include?(tab.status.to_s)
546
595
  render_tab_transient_shell_entries(tab)
596
+ render_background_tab_output(tab)
547
597
  end
548
598
  restore_tab_composer_snapshot(tab.snapshot) if restore_composer
599
+ render_pending_tab_runtime_outputs(tab)
600
+ end
601
+
602
+ def render_pending_tab_runtime_outputs(tab)
603
+ outputs = Array(tab.pending_runtime_outputs)
604
+ tab.pending_runtime_outputs = []
605
+ outputs.each { |output| runtime_output(output) }
606
+ end
607
+
608
+ def flush_pending_runtime_outputs(tab)
609
+ return false unless tab && tab == active_tab
610
+
611
+ render_pending_tab_runtime_outputs(tab)
612
+ true
549
613
  end
550
614
 
551
615
  def render_tab_transient_shell_entries(tab)
@@ -558,6 +622,17 @@ module Kward
558
622
  end
559
623
  end
560
624
 
625
+ def render_background_tab_output(tab)
626
+ output = tab.background_run&.fetch(:rendered_output, "").to_s
627
+ return if output.empty?
628
+
629
+ if @prompt.respond_to?(:say)
630
+ @prompt.say(output)
631
+ elsif @prompt.respond_to?(:write_transcript)
632
+ @prompt.write_transcript(output)
633
+ end
634
+ end
635
+
561
636
  def clear_active_tab_transient_shell_entries
562
637
  active_tab&.clear_transient_shell_entries
563
638
  end
@@ -603,21 +678,92 @@ module Kward
603
678
  index ? index + 1 : active_tab_number
604
679
  end
605
680
 
681
+ def register_background_tab_run(tab, run, output_kind: nil, &completion)
682
+ return unless tab && run
683
+
684
+ tab.background_run = {
685
+ run: run,
686
+ completion: completion,
687
+ output_kind: output_kind,
688
+ rendered_output: +""
689
+ }
690
+ tab.local_busy_activity = "running"
691
+ tab.attention = nil
692
+ update_prompt_tabs
693
+ end
694
+
695
+ def service_background_tab_runs
696
+ return unless @tabs
697
+
698
+ @tabs.each do |tab|
699
+ state = tab.background_run
700
+ next unless state
701
+
702
+ complete = state[:run].complete?
703
+ result = state[:run].result if complete
704
+ if state[:output_kind] && respond_to?(:flush_background_tab_output, true)
705
+ flush_background_tab_output(tab, state, result: result)
706
+ end
707
+ next unless complete
708
+
709
+ state[:completion]&.call(state[:run].sink, result, state) if result.is_a?(Struct)
710
+ rendered_output = state[:rendered_output].to_s
711
+ tab.append_transient_shell_entry(rendered_output) unless rendered_output.empty?
712
+ tab.background_run = nil
713
+ finish_local_busy_command_for_tab(tab)
714
+ if tab != active_tab
715
+ tab.unread = true
716
+ tab.attention = :success
717
+ end
718
+ rescue StandardError => e
719
+ tab.background_run = nil
720
+ tab.error = e.message
721
+ finish_local_busy_command_for_tab(tab)
722
+ tab.attention = :failure if tab != active_tab
723
+ end
724
+ update_prompt_tabs
725
+ end
726
+
727
+ def finish_local_busy_command_for_tab(tab)
728
+ tab.local_busy_activity = nil
729
+ @prompt.finish_busy_input if prompt_interface? && tab == active_tab && @prompt.respond_to?(:finish_busy_input)
730
+ end
731
+
606
732
  def submit_tab_input(tab, input, display_input: nil)
607
733
  return if input.to_s.strip.empty?
734
+ return unless plugin_tab_attachments_allowed?(tab, input)
608
735
 
609
736
  save_active_tab_state
610
737
  start_tab_turn(tab, input, display_input: display_input)
611
738
  start_tab_live_view(tab) if tab == active_tab
612
739
  end
613
740
 
741
+ def plugin_tab_attachments_allowed?(tab, input)
742
+ capabilities = tab.plugin_host&.capabilities
743
+ return true unless capabilities&.declared?
744
+ return true if capabilities.allows_attachment?(:image)
745
+
746
+ attached = ImageAttachments.references_from_text(input.to_s).any? { |reference| reference[:status] == :attached }
747
+ return true unless attached
748
+
749
+ runtime_output("This plugin chat does not allow image attachments.")
750
+ false
751
+ end
752
+
614
753
  def start_tab_turn(tab, input, display_input: nil)
615
754
  prepare_memory_context(tab.agent.conversation, input) if tab.agent.respond_to?(:conversation)
616
- print_user_transcript(input, display_input: display_input) if prompt_interface?
755
+ print_user_transcript(input, display_input: display_input) if prompt_interface? && tab == active_tab
617
756
  tab.status = "queued"
618
757
  tab.unread = false
758
+ tab.attention = nil
619
759
  tab.cancellation = Cancellation.new
620
- tab.steering = tab.driver.supports_steering? && steering_supported? ? Steering.new : nil
760
+ steering_capability = tab.plugin_host&.capabilities
761
+ driver_supports_steering = if steering_capability&.declared?
762
+ steering_capability.steering?
763
+ else
764
+ tab.driver.supports_steering?
765
+ end
766
+ tab.steering = driver_supports_steering && steering_supported? ? Steering.new : nil
621
767
  tab.error = nil
622
768
  tab.answer = nil
623
769
  tab.error_reported = false
@@ -639,14 +785,17 @@ module Kward
639
785
  end
640
786
  tab.status = "ready"
641
787
  tab.unread = tab != active_tab
788
+ tab.attention = :output if tab != active_tab
642
789
  rescue Cancellation::CancelledError
643
790
  tab.status = "cancelled"
644
791
  tab.unread = false
792
+ tab.attention = :failure if tab != active_tab
645
793
  report_tab_runtime_error(tab)
646
794
  rescue StandardError => e
647
795
  tab.error = e.message
648
796
  tab.status = "failed"
649
797
  tab.unread = false
798
+ tab.attention = :failure if tab != active_tab
650
799
  report_tab_runtime_error(tab)
651
800
  ensure
652
801
  finish_tab_turn(tab)
@@ -662,8 +811,9 @@ module Kward
662
811
  end
663
812
 
664
813
  def poll_active_tab_input
814
+ service_background_tab_runs
665
815
  tab = active_tab
666
- unless tab&.running?
816
+ unless tab&.running? || tab&.local_busy?
667
817
  input = @prompt.ask("You>")
668
818
  return { tab_action: :close } if input.nil? && tab && @tabs.length > 1
669
819
 
@@ -672,6 +822,7 @@ module Kward
672
822
 
673
823
  @prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
674
824
  loop do
825
+ service_background_tab_runs
675
826
  refresh_active_tab
676
827
  if service_active_tab_question
677
828
  return next_tab_queued_input(tab) if tab.idle? && !tab.queued_inputs.empty?
@@ -679,6 +830,10 @@ module Kward
679
830
 
680
831
  next
681
832
  end
833
+ if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
834
+ sleep 0.01
835
+ next
836
+ end
682
837
  poll_result = @prompt.poll_input
683
838
  case poll_result
684
839
  when Hash
@@ -687,8 +842,12 @@ module Kward
687
842
  return poll_result
688
843
  end
689
844
  when PromptInterface::CANCEL_INPUT
690
- tab.cancellation&.cancel!
691
- tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
845
+ if tab.background_run
846
+ tab.background_run[:run].cancel!
847
+ else
848
+ tab.cancellation&.cancel!
849
+ tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
850
+ end
692
851
  when PromptInterface::EXIT_INPUT
693
852
  tab.queued_inputs << "/exit"
694
853
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
@@ -716,8 +875,12 @@ module Kward
716
875
  tab.queued_inputs << input
717
876
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
718
877
  return
719
- elsif busy_tab_command?(input)
720
- handle_tab_command(input.to_s.strip.sub(%r{\A/tab(?:\s+|\z)}, ""), @session_store)
878
+ elsif busy_tab_command?(input) || busy_name_command?(input)
879
+ if busy_tab_command?(input)
880
+ handle_tab_command(input.to_s.strip.sub(%r{\A/tab(?:\s+|\z)}, ""), @session_store)
881
+ else
882
+ handle_local_slash_command(input, tab.agent, @session_store)
883
+ end
721
884
  unless active_tab.equal?(tab)
722
885
  @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
723
886
  return :active_tab_changed
@@ -749,6 +912,10 @@ module Kward
749
912
  input.to_s.strip.match?(%r{\A/tab(?:\s|\z)})
750
913
  end
751
914
 
915
+ def busy_name_command?(input)
916
+ input.to_s.strip.match?(%r{\A/name(?:\s|\z)})
917
+ end
918
+
752
919
  def refresh_active_tab
753
920
  tab = active_tab
754
921
  return unless tab&.thread && !tab.thread.alive? && tab.running?
@@ -830,15 +997,17 @@ module Kward
830
997
  return if tab.stream_state[:streamed]
831
998
  return if tab.answer.to_s.empty?
832
999
 
833
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(tab.answer)}\n")
1000
+ @prompt.say("\n#{colored(transcript_marker("Assistant"), :activity, :bold)} #{render_markdown_transcript(tab.answer)}\n")
834
1001
  end
835
1002
 
836
1003
  def new_tab_stream_state(driver)
837
1004
  {
838
1005
  streamed: false,
1006
+ assistant_arrived: false,
839
1007
  last_flush: monotonic_now,
840
1008
  stream_block_open: false,
841
1009
  markdown_streams: {},
1010
+ tool_elapsed_ms: {},
842
1011
  defer_assistant_streaming: driver.respond_to?(:conversation) && defer_assistant_streaming?(driver.agent)
843
1012
  }
844
1013
  end
@@ -853,10 +1022,11 @@ module Kward
853
1022
  end
854
1023
 
855
1024
  def tab_label_color(tab)
856
- return :green if tab.status.to_s == "waiting_for_question"
857
- return :yellow if tab.running? || tab.local_busy?
858
- return :red if %w[failed cancelled].include?(tab.status.to_s)
859
- return :green if tab.unread
1025
+ return :success if tab.status.to_s == "waiting_for_question"
1026
+ return :caution if tab.running? || tab.local_busy?
1027
+ return :failure if %w[failed cancelled].include?(tab.status.to_s) || tab.attention == :failure
1028
+ return :success if tab.attention == :success
1029
+ return :success if tab.unread
860
1030
 
861
1031
  nil
862
1032
  end
@@ -906,7 +1076,7 @@ module Kward
906
1076
  end
907
1077
 
908
1078
  def tab_usage
909
- "/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]"
1079
+ "/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 [resolve|continue|abort]|remove]"
910
1080
  end
911
1081
 
912
1082
  def switch_tab_number(number)
@@ -970,17 +1140,58 @@ module Kward
970
1140
  tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
971
1141
  tab.thread&.join(0.2)
972
1142
  end
973
- close_ekwsh_session(tab.shell) if tab&.shell && respond_to?(:close_ekwsh_session, true)
1143
+ if (background_run = tab.background_run&.fetch(:run))
1144
+ background_run.cancel!
1145
+ background_run.join(0.5)
1146
+ end
1147
+ tab.background_run = nil
1148
+ close_kwsh_session(tab.shell) if tab&.shell && respond_to?(:close_kwsh_session, true)
1149
+ close_plugin_tab(tab)
974
1150
  rescue StandardError
975
1151
  nil
976
1152
  end
977
1153
  end
978
1154
 
1155
+ def build_plugin_tab_host(tab_type, workspace_root:, scope_key:)
1156
+ PluginTabHost.new(
1157
+ client: @client,
1158
+ workspace_root: workspace_root,
1159
+ plugin_host: tab_type.plugin_id && plugin_registry.plugin_for(tab_type.plugin_id),
1160
+ type_id: tab_type.id,
1161
+ surface: :local,
1162
+ scope_key: scope_key,
1163
+ capabilities: tab_type.capabilities
1164
+ )
1165
+ end
1166
+
1167
+ def plugin_tab_scope_key(tab_type)
1168
+ tab_type.singleton == :global ? "global" : SecureRandom.uuid
1169
+ end
1170
+
1171
+ def close_plugin_tab(tab)
1172
+ return unless tab&.plugin_host
1173
+
1174
+ if tab.driver.respond_to?(:close)
1175
+ tab.driver.close
1176
+ elsif tab.driver.respond_to?(:shutdown)
1177
+ tab.driver.shutdown
1178
+ end
1179
+ rescue StandardError => e
1180
+ runtime_output("Plugin tab cleanup error: #{e.message}")
1181
+ ensure
1182
+ tab&.plugin_host&.shutdown
1183
+ tab.plugin_host = nil if tab
1184
+ end
1185
+
979
1186
  def persist_tabs
980
1187
  return unless @tab_store
981
1188
 
982
1189
  @tab_store.save(
983
- tabs: @tabs.map { |tab| tab.driver.descriptor.merge("label" => tab.label.to_s) },
1190
+ tabs: @tabs.map do |tab|
1191
+ descriptor = tab.driver.descriptor.merge("label" => tab.label.to_s)
1192
+ descriptor["scope_key"] ||= tab.plugin_host.scope_key if tab.plugin_host
1193
+ descriptor
1194
+ end,
984
1195
  active_index: @active_tab_index
985
1196
  )
986
1197
  end