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
@@ -223,7 +223,10 @@ module Kward
223
223
  # Writes the user transcript output for the terminal CLI flow.
224
224
  def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
225
225
  visible_input = display_input.nil? ? input : display_input
226
- write_prompt_transcript("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
226
+ write_prompt_transcript("\n#{colored("❯ You>", :blue, :bold)} #{visible_input}\n")
227
+ # System-turn instructions are plain text, never image attachments.
228
+ return if input.is_a?(PluginTurnRequest)
229
+
227
230
  print_attachment_badges(input, references: attachment_references)
228
231
  print_pasted_images(input, image_parts: image_parts)
229
232
  end
@@ -347,8 +350,9 @@ module Kward
347
350
  end
348
351
 
349
352
  # Writes the tool result output for the terminal CLI flow.
350
- def print_tool_result(tool_call, content, line_limit: nil)
353
+ def print_tool_result(tool_call, content, line_limit: nil, elapsed_ms: nil)
351
354
  summary = tool_result_summary(tool_call, content)
355
+ summary = tool_summary_with_duration(summary, elapsed_ms)
352
356
  summary = limit_tool_output_lines(summary, line_limit) if line_limit
353
357
  display_summary = tool_summary_display_text(summary)
354
358
  if prompt_interface?
@@ -380,7 +384,7 @@ module Kward
380
384
  return if @stream_block == label
381
385
 
382
386
  puts if @stream_block
383
- print "\n#{colored("#{transcript_label(label)}>", *label_styles(label))} "
387
+ print "\n#{colored(transcript_marker(label), *label_styles(label))} "
384
388
  @stream_block = label
385
389
  end
386
390
 
@@ -408,20 +412,25 @@ module Kward
408
412
  end
409
413
  end
410
414
 
415
+ def transcript_marker(label)
416
+ marker = "#{transcript_label(label)}>"
417
+ label == "Assistant" ? "✦ #{marker}" : marker
418
+ end
419
+
411
420
  def label_styles(label)
412
421
  case label
413
422
  when "Reasoning", "Compaction summary"
414
- [:gray, :bold]
423
+ [:metadata, :bold]
415
424
  when "Assistant", "Kward"
416
- [:green, :bold]
425
+ [:activity, :bold]
417
426
  when "Tool", "Tool output"
418
- [:cyan, :bold]
427
+ [:tool, :bold]
419
428
  when "Tool failed"
420
- [:red, :bold]
429
+ [:failure, :bold]
421
430
  when "Retry"
422
- [:yellow, :bold]
431
+ [:caution, :bold]
423
432
  else
424
- [:gray, :bold]
433
+ [:metadata, :bold]
425
434
  end
426
435
  end
427
436
 
@@ -47,6 +47,11 @@ module Kward
47
47
 
48
48
  def runtime_output(text)
49
49
  content = text.to_s.chomp
50
+ if (tab = Thread.current[:kward_local_busy_tab]) && !content.empty?
51
+ tab.append_pending_runtime_output(content)
52
+ return
53
+ end
54
+
50
55
  label = colored(runtime_output_prompt, :gray, :bold)
51
56
  separator = content.include?("\n") ? "\n" : " "
52
57
  @prompt.say("\n#{label}#{separator}#{content}\n")
@@ -61,6 +66,7 @@ module Kward
61
66
  workspace: workspace,
62
67
  prompt: @prompt,
63
68
  skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths_for(conversation.workspace_root)),
69
+ plugin_tools: plugin_registry.tools,
64
70
  tool_approval: interactive_tool_approval_callback,
65
71
  hook_manager: hook_manager,
66
72
  hook_context: hook_context
@@ -102,19 +108,19 @@ module Kward
102
108
  if editor_result
103
109
  record_tab_transient_shell_output(editor_result.output, render: false)
104
110
  @prompt.say(editor_result.output) unless editor_result.output.to_s.empty?
105
- open_ekwsh_editor(editor_result.open_editor_path, shell) if editor_result.open_editor_path
111
+ open_kwsh_editor(editor_result.open_editor_path, shell) if editor_result.open_editor_path
106
112
  return true
107
113
  end
108
114
 
109
115
  expanded_command = shell.expand_alias(command, interactive: true)
110
- run_user_interactive_pty_command(
116
+ result = run_user_interactive_pty_command(
111
117
  expanded_command,
112
- shell: Ekwsh::DEFAULT_SHELL,
113
- env: interactive_pty_environment({}, preserve_git_pager: true),
118
+ shell: Kwsh::DEFAULT_SHELL,
119
+ env: interactive_pty_environment(shell.child_env, preserve_git_pager: true),
114
120
  cwd: interactive_workspace_root(agent),
115
121
  intro: "$ #{command}\n"
116
122
  )
117
- true
123
+ result&.tab_action ? :tab_action : true
118
124
  end
119
125
 
120
126
  def run_captured_shell_command(command, agent)
@@ -142,7 +148,7 @@ module Kward
142
148
  completion = bang_shell(agent).complete(value[1..], cursor.to_i - 1)
143
149
  return nil unless completion
144
150
 
145
- Ekwsh::Completion.new(
151
+ Kwsh::Completion.new(
146
152
  range: (completion.range.begin + 1)...(completion.range.end + 1),
147
153
  replacement: completion.replacement,
148
154
  candidates: completion.candidates
@@ -151,12 +157,14 @@ module Kward
151
157
 
152
158
  def bang_shell(agent)
153
159
  root = File.expand_path(interactive_workspace_root(agent).to_s)
154
- aliases = ConfigFiles.read_ekwsh_config[:aliases]
155
- cache_key = [root, ENV.fetch("PATH", ""), aliases.sort]
160
+ config = ConfigFiles.read_kwsh_config
161
+ aliases = config[:aliases]
162
+ configured_env = config[:env]
163
+ cache_key = [root, ENV.fetch("PATH", ""), aliases.sort, configured_env.sort]
156
164
  return @bang_shell if @bang_shell_key == cache_key
157
165
 
158
166
  @bang_shell_key = cache_key
159
- @bang_shell = Ekwsh.new(cwd: root, env: ENV.to_h, aliases: aliases)
167
+ @bang_shell = Kwsh.new(cwd: root, env: ENV.to_h, configured_env: configured_env, aliases: aliases)
160
168
  end
161
169
 
162
170
  def install_bang_completion_provider(agent)
@@ -182,7 +190,7 @@ module Kward
182
190
  @bang_completion_provider_installed = false
183
191
  end
184
192
 
185
- def run_ekwsh(agent)
193
+ def run_kwsh(agent)
186
194
  unless @prompt.respond_to?(:ask)
187
195
  runtime_output("The embedded shell is only available in interactive mode.")
188
196
  return
@@ -190,11 +198,11 @@ module Kward
190
198
 
191
199
  tab = active_tab if respond_to?(:active_tab, true)
192
200
  entering = tab.nil? || tab.shell.nil?
193
- shell = tab&.shell || build_ekwsh(agent)
201
+ shell = tab&.shell || build_kwsh(agent)
194
202
  tab.shell = shell if tab
195
203
  shell_agent = shell_prompt_agent_for(agent, tab: tab)
196
- runtime_output("Entering ekwsh. Type exit or press Ctrl+D on an empty prompt to return.") if entering
197
- run_ekwsh_loop(shell, tab: tab, history: build_ekwsh_history(agent), shell_agent: shell_agent)
204
+ runtime_output("Entering kwsh. Type exit or press Ctrl+D on an empty prompt to return.") if entering
205
+ run_kwsh_loop(shell, tab: tab, history: build_kwsh_history(agent), shell_agent: shell_agent)
198
206
  end
199
207
 
200
208
  def shell_prompt_agent_for(agent, tab: nil)
@@ -208,17 +216,20 @@ module Kward
208
216
 
209
217
  def build_shell_prompt_agent(active_agent)
210
218
  active_conversation = active_agent.conversation
219
+ config = ConfigFiles.read_config
220
+ configured_provider = ConfigFiles.shell_agent_provider(config)
211
221
  conversation = Conversation.new(
212
222
  system_message: ShellPrompt.system_message,
213
223
  workspace_root: active_conversation.workspace_root,
214
- provider: active_conversation.provider || current_model_provider,
215
- model: active_conversation.model || current_model_id,
216
- reasoning_effort: active_conversation.reasoning_effort || current_reasoning_effort
224
+ provider: configured_provider || active_conversation.provider || current_model_provider,
225
+ model: ConfigFiles.shell_agent_model(config) || (configured_provider ? nil : active_conversation.model || current_model_id),
226
+ reasoning_effort: ConfigFiles.shell_agent_reasoning_effort(config) || (configured_provider ? nil : active_conversation.reasoning_effort || current_reasoning_effort)
217
227
  )
218
228
  Agent.new(
219
229
  client: @client,
220
230
  tool_registry: active_agent.tool_registry,
221
231
  conversation: conversation,
232
+ strict_provider: !configured_provider.nil?,
222
233
  warning_sink: ConfigFiles.warning_sink
223
234
  )
224
235
  end
@@ -269,8 +280,8 @@ module Kward
269
280
  input.to_s.start_with?("?")
270
281
  end
271
282
 
272
- def build_ekwsh(agent)
273
- config = ConfigFiles.read_ekwsh_config
283
+ def build_kwsh(agent)
284
+ config = ConfigFiles.read_kwsh_config
274
285
  PersistentShellSession.new(
275
286
  cwd: interactive_workspace_root(agent),
276
287
  configured_env: config[:env],
@@ -281,8 +292,8 @@ module Kward
281
292
  )
282
293
  end
283
294
 
284
- def build_ekwsh_history(agent)
285
- config = ConfigFiles.read_ekwsh_config
295
+ def build_kwsh_history(agent)
296
+ config = ConfigFiles.read_kwsh_config
286
297
  PromptHistory.new(
287
298
  cwd: interactive_workspace_root(agent),
288
299
  limit: config[:history_limit],
@@ -297,7 +308,7 @@ module Kward
297
308
  return
298
309
  end
299
310
 
300
- config = ConfigFiles.read_ekwsh_config
311
+ config = ConfigFiles.read_kwsh_config
301
312
  run_user_interactive_pty_command(
302
313
  command,
303
314
  shell: config[:shell],
@@ -314,8 +325,14 @@ module Kward
314
325
  elsif @prompt.respond_to?(:say)
315
326
  @prompt.say(intro_message)
316
327
  end
317
- result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd) do |sink, completed_result|
318
- transcript_output = record_completed_pty_output(sink, completed_result)
328
+ origin_tab = active_tab if respond_to?(:active_tab, true)
329
+ result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd) do |sink, completed_result, background_state|
330
+ transcript_output = record_completed_pty_output(
331
+ sink,
332
+ completed_result,
333
+ tab: origin_tab,
334
+ background_state: background_state
335
+ )
319
336
  yield(transcript_output, completed_result) if block_given?
320
337
  end
321
338
  refresh_composer_status
@@ -327,20 +344,35 @@ module Kward
327
344
 
328
345
  def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, &on_complete)
329
346
  runner = InteractivePtyRunner.new
347
+ origin_tab = active_tab if respond_to?(:active_tab, true)
330
348
  with_interactive_terminal_handoff do |input, sink|
349
+ tab_action_handler = terminal_handoff_tab_action_handler
350
+ on_detach = if origin_tab
351
+ lambda do
352
+ BufferedPtyOutputSink.new(
353
+ max_capture_bytes: Kward::CLI::Tabs::TRANSIENT_SHELL_OUTPUT_LIMIT,
354
+ initial_output: sink.captured_output,
355
+ initial_truncated: sink.truncated?
356
+ )
357
+ end
358
+ end
331
359
  result = if shell.respond_to?(:run_interactive)
332
- shell.run_interactive(command, input: input, sink: sink)
360
+ shell.run_interactive(command, input: input, sink: sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
361
+ else
362
+ runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, sink: sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
363
+ end
364
+ if result&.background
365
+ register_background_tab_run(origin_tab, result.background, output_kind: :pty, &on_complete)
333
366
  else
334
- runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, sink: sink)
367
+ on_complete.call(sink, result) if on_complete
335
368
  end
336
- on_complete.call(sink, result) if on_complete
337
369
  result
338
370
  end
339
371
  end
340
372
 
341
373
  def with_interactive_terminal_handoff
342
374
  if @prompt.respond_to?(:with_inline_terminal_handoff)
343
- @prompt.with_inline_terminal_handoff do |input, output, transition|
375
+ with_tab_keybinding_handoff(:with_inline_terminal_handoff) do |input, output, transition|
344
376
  sink = AdaptivePtyOutputSink.new(
345
377
  output: output,
346
378
  on_exclusive: transition,
@@ -349,7 +381,7 @@ module Kward
349
381
  yield(input, sink)
350
382
  end
351
383
  elsif @prompt.respond_to?(:with_terminal_handoff)
352
- @prompt.with_terminal_handoff do |input, output|
384
+ with_tab_keybinding_handoff(:with_terminal_handoff) do |input, output|
353
385
  sink = PassthroughPtyOutputSink.new(
354
386
  output: output,
355
387
  max_capture_bytes: MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES
@@ -365,7 +397,100 @@ module Kward
365
397
  end
366
398
  end
367
399
 
368
- def record_completed_pty_output(sink, result)
400
+ def with_tab_keybinding_handoff(method_name)
401
+ method = @prompt.method(method_name)
402
+ supports_option = method.parameters.any? do |type, name|
403
+ type == :keyrest || (type == :key || type == :keyreq) && name == :preserve_tab_keybindings
404
+ end
405
+ if supports_option
406
+ @prompt.public_send(method_name, preserve_tab_keybindings: true) { |*args| yield(*args) }
407
+ else
408
+ @prompt.public_send(method_name) { |*args| yield(*args) }
409
+ end
410
+ end
411
+
412
+ def terminal_handoff_tab_action_handler
413
+ return unless @prompt.respond_to?(:filter_terminal_handoff_input)
414
+ return unless respond_to?(:handle_tab_action, true)
415
+
416
+ lambda do |chunk|
417
+ filtered = @prompt.filter_terminal_handoff_input(chunk)
418
+ action = filtered[:tab_action]
419
+ handle_tab_action(action, @session_store) if action
420
+ filtered
421
+ end
422
+ end
423
+
424
+ def record_completed_pty_output(sink, result, tab: nil, background_state: nil)
425
+ transcript_output = background_shell_transcript_output(sink, result)
426
+ return unless transcript_output
427
+
428
+ rendered_output = background_state&.fetch(:rendered_output, "").to_s
429
+ delta = if transcript_output.start_with?(rendered_output)
430
+ transcript_output[rendered_output.length..].to_s
431
+ else
432
+ transcript_output
433
+ end
434
+ if background_state
435
+ background_state[:rendered_output] = transcript_output
436
+ render_background_tab_delta(tab, delta) unless delta.empty?
437
+ else
438
+ record_tab_transient_shell_output(transcript_output, render: false, tab: tab)
439
+ if @prompt.respond_to?(:record_transient_terminal_output) && (!tab || tab == active_tab)
440
+ @prompt.record_transient_terminal_output(transcript_output, render: false)
441
+ end
442
+ end
443
+ transcript_output
444
+ end
445
+
446
+ def flush_background_tab_output(tab, state, result: nil)
447
+ sink = state[:run].sink
448
+ output = sink&.captured_output.to_s
449
+ return if output.empty?
450
+
451
+ transcript_output = if state[:output_kind] == :streamed
452
+ background_stream_transcript_output(sink)
453
+ else
454
+ transcript_result = result || InteractivePtyRunner::Result.new(exit_status: 0, input_forwarded: false)
455
+ background_shell_transcript_output(sink, transcript_result)
456
+ end
457
+ return unless transcript_output
458
+
459
+ rendered_output = state[:rendered_output].to_s
460
+ if transcript_output.start_with?(rendered_output)
461
+ delta = transcript_output[rendered_output.length..].to_s
462
+ return if delta.empty?
463
+
464
+ state[:rendered_output] = transcript_output
465
+ render_background_tab_delta(tab, delta)
466
+ else
467
+ state[:rendered_output] = transcript_output
468
+ render_tab(tab, restore_composer: false) if tab == active_tab
469
+ end
470
+ end
471
+
472
+ def render_background_tab_delta(tab, delta)
473
+ return unless tab == active_tab
474
+
475
+ if @prompt.respond_to?(:record_transient_terminal_output)
476
+ @prompt.record_transient_terminal_output(delta)
477
+ elsif @prompt.respond_to?(:say)
478
+ @prompt.say(delta)
479
+ end
480
+ end
481
+
482
+ def background_stream_transcript_output(sink)
483
+ return if sink&.truncated?
484
+
485
+ text = ANSI.normalize_transcript_encoding(sink&.captured_output.to_s).gsub("\r\n", "\n")
486
+ sanitized = PtyTranscriptNormalizer.normalize(text)
487
+ return if sanitized.empty?
488
+ return if ANSI.strip_control_sequences(sanitized).match?(UNSAFE_TRANSCRIPT_CONTROL_PATTERN)
489
+
490
+ sanitized
491
+ end
492
+
493
+ def background_shell_transcript_output(sink, result)
369
494
  classified_output = sink.respond_to?(:transcript_safe?)
370
495
  return if classified_output && !sink.transcript_safe?
371
496
 
@@ -373,20 +498,13 @@ module Kward
373
498
  allow_input = classified_output &&
374
499
  sink.respond_to?(:pre_input_capture_only?) &&
375
500
  sink.pre_input_capture_only?
376
- transcript_output = terminal_transcript_output(
501
+ terminal_transcript_output(
377
502
  output,
378
503
  result,
379
504
  truncated: sink&.truncated? || false,
380
505
  allow_input: allow_input,
381
506
  normalize_line_controls: classified_output
382
507
  )
383
- return unless transcript_output
384
-
385
- record_tab_transient_shell_output(transcript_output, render: false)
386
- if @prompt.respond_to?(:record_transient_terminal_output)
387
- @prompt.record_transient_terminal_output(transcript_output, render: false)
388
- end
389
- transcript_output
390
508
  end
391
509
 
392
510
  def terminal_transcript_output(
@@ -422,14 +540,14 @@ module Kward
422
540
  end
423
541
  end
424
542
 
425
- def run_ekwsh_loop(shell, tab: nil, history: nil, shell_agent: nil)
543
+ def run_kwsh_loop(shell, tab: nil, history: nil, shell_agent: nil)
426
544
  shell_agent ||= shell_prompt_agent_for(tab.agent, tab: tab) if tab&.agent
427
- with_ekwsh_history(history) do
428
- run_ekwsh_loop_with_history(shell, tab: tab, shell_agent: shell_agent)
545
+ with_kwsh_history(history) do
546
+ run_kwsh_loop_with_history(shell, tab: tab, shell_agent: shell_agent)
429
547
  end
430
548
  end
431
549
 
432
- def run_ekwsh_loop_with_history(shell, tab: nil, shell_agent: nil)
550
+ def run_kwsh_loop_with_history(shell, tab: nil, shell_agent: nil)
433
551
  pending_shell_inputs = []
434
552
  loop do
435
553
  if @prompt.respond_to?(:editing_file?) && @prompt.editing_file?
@@ -440,7 +558,7 @@ module Kward
440
558
  end
441
559
  end
442
560
 
443
- input = pending_shell_inputs.shift || ask_ekwsh(shell)
561
+ input = pending_shell_inputs.shift || ask_kwsh(shell)
444
562
  if input.is_a?(Hash) && input[:tab_action]
445
563
  (@pending_inputs ||= []).unshift(input)
446
564
  return :tab_action
@@ -452,7 +570,7 @@ module Kward
452
570
  next
453
571
  end
454
572
 
455
- result = run_ekwsh_command(shell, input)
573
+ result = run_kwsh_command(shell, input)
456
574
  if result.clear
457
575
  clear_active_tab_transient_shell_output
458
576
  @prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
@@ -463,37 +581,39 @@ module Kward
463
581
  return :tab_action if pending_tab_action?
464
582
 
465
583
  if result.open_editor_path
466
- editor_result = open_ekwsh_editor(result.open_editor_path, shell)
584
+ editor_result = open_kwsh_editor(result.open_editor_path, shell)
467
585
  return :tab_action if editor_result == :tab_action
468
586
 
469
587
  next
470
588
  end
471
589
  if result.interactive_command
472
- run_ekwsh_interactive_pty_command(shell, result)
590
+ pty_result = run_kwsh_interactive_pty_command(shell, result)
591
+ return :tab_action if pty_result&.tab_action
592
+
473
593
  next
474
594
  end
475
595
  if result.exit_shell
476
- close_ekwsh_session(shell)
596
+ close_kwsh_session(shell)
477
597
  tab.shell = nil if tab
478
598
  tab.shell_agent = nil if tab
479
599
  runtime_output("Shell exited.")
480
600
  return :exited
481
601
  end
482
602
  end
483
- close_ekwsh_session(shell)
603
+ close_kwsh_session(shell)
484
604
  tab.shell = nil if tab
485
605
  tab.shell_agent = nil if tab
486
606
  runtime_output("Shell exited.")
487
607
  :exited
488
608
  end
489
609
 
490
- def close_ekwsh_session(shell)
610
+ def close_kwsh_session(shell)
491
611
  shell.close if shell.respond_to?(:close)
492
612
  rescue StandardError
493
613
  nil
494
614
  end
495
615
 
496
- def with_ekwsh_history(history)
616
+ def with_kwsh_history(history)
497
617
  if history && @prompt.respond_to?(:with_prompt_history)
498
618
  @prompt.with_prompt_history(history) { yield }
499
619
  else
@@ -501,7 +621,7 @@ module Kward
501
621
  end
502
622
  end
503
623
 
504
- def open_ekwsh_editor(path, shell)
624
+ def open_kwsh_editor(path, shell)
505
625
  unless @prompt.respond_to?(:edit_file)
506
626
  runtime_output("Integrated editor is unavailable in this prompt.")
507
627
  return false
@@ -516,7 +636,7 @@ module Kward
516
636
  result
517
637
  end
518
638
 
519
- def ask_ekwsh(shell)
639
+ def ask_kwsh(shell)
520
640
  provider = ->(input, cursor) { shell.complete(input, cursor) }
521
641
  if @prompt.respond_to?(:with_completion_provider)
522
642
  @prompt.with_completion_provider(provider, slash_overlay: false) { @prompt.ask(shell.prompt_label) }
@@ -525,7 +645,7 @@ module Kward
525
645
  end
526
646
  end
527
647
 
528
- def run_ekwsh_interactive_pty_command(shell, result)
648
+ def run_kwsh_interactive_pty_command(shell, result)
529
649
  shell_target = shell.respond_to?(:run_interactive) ? shell : shell.command_shell
530
650
  run_user_interactive_pty_command(
531
651
  result.interactive_command,
@@ -541,12 +661,12 @@ module Kward
541
661
  end
542
662
  end
543
663
 
544
- def run_ekwsh_command(shell, input)
664
+ def run_kwsh_command(shell, input)
545
665
  if @prompt.respond_to?(:begin_busy_input)
546
666
  @prompt.begin_busy_input(shell.prompt_label, activity: "running")
547
667
  end
548
668
  if @prompt.respond_to?(:write_transcript_delta) && @prompt.respond_to?(:poll_input)
549
- run_streaming_ekwsh_command(shell, input)
669
+ run_streaming_kwsh_command(shell, input)
550
670
  elsif @prompt.respond_to?(:write_transcript_delta)
551
671
  shell.run(input) { |chunk| @prompt.write_transcript_delta(chunk) }
552
672
  else
@@ -556,45 +676,70 @@ module Kward
556
676
  @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
557
677
  end
558
678
 
559
- def run_streaming_ekwsh_command(shell, input)
679
+ def run_streaming_kwsh_command(shell, input)
560
680
  cancellation = Cancellation.new
561
681
  chunks = Queue.new
562
682
  queued_inputs = []
683
+ origin_tab = active_tab if respond_to?(:active_tab, true)
684
+ streamed_output = BufferedPtyOutputSink.new(max_capture_bytes: Kward::CLI::Tabs::TRANSIENT_SHELL_OUTPUT_LIMIT)
685
+ detached_sink = nil
563
686
  result = nil
564
687
  error = nil
565
688
  worker = Thread.new do
566
- result = shell.run(input, cancellation: cancellation) { |chunk| chunks << chunk }
689
+ result = shell.run(input, cancellation: cancellation) do |chunk|
690
+ if detached_sink
691
+ detached_sink.write(chunk)
692
+ else
693
+ streamed_output.write(chunk)
694
+ chunks << chunk
695
+ end
696
+ end
567
697
  rescue StandardError => e
568
698
  error = e
569
699
  end
570
700
  worker.report_on_exception = false
571
701
 
572
702
  while worker.alive?
573
- drain_ekwsh_chunks(chunks)
703
+ drain_kwsh_chunks(chunks)
574
704
  poll_result = collect_queued_input(queued_inputs)
575
705
  if poll_result == PromptInterface::CANCEL_INPUT
576
706
  cancellation.cancel!
577
707
  elsif poll_result.is_a?(Hash) && poll_result[:tab_action]
578
708
  (@pending_inputs ||= []).unshift(poll_result)
709
+ if origin_tab
710
+ detached_sink = BufferedPtyOutputSink.new(
711
+ max_capture_bytes: Kward::CLI::Tabs::TRANSIENT_SHELL_OUTPUT_LIMIT,
712
+ initial_output: streamed_output.captured_output,
713
+ initial_truncated: streamed_output.truncated?
714
+ )
715
+ background = DetachedRun.new(sink: detached_sink, canceler: -> { cancellation.cancel! }) do
716
+ worker.join
717
+ raise error if error
718
+
719
+ result
720
+ end
721
+ register_background_tab_run(origin_tab, background, output_kind: :streamed)
722
+ return Kwsh::Result.new(output: "", exit_status: 0, streamed: true)
723
+ end
579
724
  cancellation.cancel!
580
725
  end
581
726
  sleep 0.01
582
727
  end
583
728
  worker.join
584
- drain_ekwsh_chunks(chunks)
729
+ drain_kwsh_chunks(chunks)
585
730
  raise error if error
586
731
 
587
732
  queued_inputs.reverse_each { |pending_input| (@pending_inputs ||= []).unshift(pending_input) }
588
733
  result
589
734
  end
590
735
 
591
- def record_tab_transient_shell_output(text, render: true)
736
+ def record_tab_transient_shell_output(text, render: true, tab: nil)
592
737
  value = text.to_s
593
738
  return if value.empty?
594
739
 
595
- tab = active_tab if respond_to?(:active_tab, true)
740
+ tab ||= active_tab if respond_to?(:active_tab, true)
596
741
  tab&.append_transient_shell_entry(value)
597
- if render && @prompt.respond_to?(:record_transient_terminal_output)
742
+ if render && @prompt.respond_to?(:record_transient_terminal_output) && tab == active_tab
598
743
  @prompt.record_transient_terminal_output(value)
599
744
  end
600
745
  end
@@ -641,7 +786,7 @@ module Kward
641
786
  after
642
787
  end
643
788
 
644
- def drain_ekwsh_chunks(chunks)
789
+ def drain_kwsh_chunks(chunks)
645
790
  loop do
646
791
  @prompt.write_transcript_delta(chunks.pop(true))
647
792
  rescue ThreadError
@@ -674,44 +819,56 @@ module Kward
674
819
  queued_inputs = []
675
820
  result = nil
676
821
  error = nil
677
- begin_local_busy_command(activity)
822
+ cancelled = false
823
+ origin_tab = active_tab
824
+ cancellation = Cancellation.new
825
+ begin_local_busy_command(activity, tab: origin_tab)
678
826
 
679
827
  worker = Thread.new do
680
- result = yield
828
+ Thread.current[:kward_local_busy_tab] = origin_tab
829
+ result = yield(cancellation)
681
830
  rescue StandardError => e
682
831
  error = e
832
+ ensure
833
+ Thread.current[:kward_local_busy_tab] = nil
683
834
  end
684
835
 
685
836
  while worker.alive?
686
837
  poll_result = collect_queued_input(queued_inputs)
838
+ if poll_result == PromptInterface::CANCEL_INPUT && !cancelled
839
+ cancelled = true
840
+ cancellation.cancel!
841
+ end
687
842
  handle_busy_local_tab_action(poll_result, activity: activity) if busy_local_tab_action?(poll_result)
688
843
  sleep 0.02
689
844
  end
690
845
  worker.join
691
846
  drain_queued_input(queued_inputs)
692
- raise error if error
847
+ flushed = flush_pending_runtime_outputs(origin_tab)
848
+ origin_tab.unread = true if origin_tab && !flushed && origin_tab.pending_runtime_outputs.any?
849
+ raise error if error && !error.is_a?(Cancellation::CancelledError)
693
850
 
694
851
  [result, queued_inputs]
695
852
  ensure
696
- finish_local_busy_command(activity)
853
+ finish_local_busy_command(activity, tab: origin_tab)
697
854
  end
698
855
 
699
856
  def run_busy_local_command_and_requeue(activity: "loading")
700
857
  return yield unless prompt_interface?
701
858
 
702
- result, queued_inputs = run_busy_local_command(activity: activity) { yield }
859
+ result, queued_inputs = run_busy_local_command(activity: activity) { |cancellation| yield(cancellation) }
703
860
  queued_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
704
861
  result
705
862
  end
706
863
 
707
- def begin_local_busy_command(activity)
708
- active_tab.local_busy_activity = activity if active_tab
864
+ def begin_local_busy_command(activity, tab: active_tab)
865
+ tab.local_busy_activity = activity if tab
709
866
  @prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
710
867
  update_prompt_tabs if respond_to?(:update_prompt_tabs, true)
711
868
  end
712
869
 
713
- def finish_local_busy_command(activity)
714
- tab = local_busy_tab(activity)
870
+ def finish_local_busy_command(activity, tab: nil)
871
+ tab ||= local_busy_tab(activity)
715
872
  tab.local_busy_activity = nil if tab
716
873
  update_prompt_tabs if respond_to?(:update_prompt_tabs, true)
717
874
  @prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input) && (!active_tab || active_tab == tab)