kward 0.84.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/doc/agent-tools.md +13 -1
  6. data/doc/api.md +17 -2
  7. data/doc/composer.md +1 -1
  8. data/doc/configuration.md +27 -4
  9. data/doc/extensibility.md +2 -1
  10. data/doc/git.md +3 -1
  11. data/doc/pan.md +6 -0
  12. data/doc/permissions.md +4 -4
  13. data/doc/plugins.md +464 -15
  14. data/doc/rpc.md +154 -16
  15. data/doc/sandboxing.md +11 -5
  16. data/doc/security.md +7 -1
  17. data/doc/session-management.md +5 -4
  18. data/doc/tabs.md +6 -2
  19. data/doc/transports.md +15 -0
  20. data/doc/usage.md +4 -1
  21. data/doc/workspace-tools.md +9 -0
  22. data/examples/plugins/space_invaders.rb +1 -1
  23. data/examples/plugins/stardate_footer.rb +2 -2
  24. data/examples/plugins/telegram/plugin.rb +1 -1
  25. data/lib/kward/agent.rb +24 -11
  26. data/lib/kward/cli/compaction.rb +9 -3
  27. data/lib/kward/cli/plugins.rb +81 -12
  28. data/lib/kward/cli/prompt_interface.rb +25 -5
  29. data/lib/kward/cli/rendering.rb +3 -0
  30. data/lib/kward/cli/runtime_helpers.rb +121 -27
  31. data/lib/kward/cli/sessions.rb +9 -5
  32. data/lib/kward/cli/settings/menus.rb +745 -0
  33. data/lib/kward/cli/settings/model.rb +327 -0
  34. data/lib/kward/cli/settings.rb +6 -1055
  35. data/lib/kward/cli/slash_commands.rb +45 -4
  36. data/lib/kward/cli/tabs.rb +167 -27
  37. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +3 -3
  38. data/lib/kward/cli/worktrees.rb +65 -2
  39. data/lib/kward/cli.rb +26 -24
  40. data/lib/kward/compactor.rb +18 -7
  41. data/lib/kward/config/core.rb +389 -0
  42. data/lib/kward/config/extensions.rb +96 -0
  43. data/lib/kward/config/prompts.rb +313 -0
  44. data/lib/kward/config/settings.rb +250 -0
  45. data/lib/kward/config_files.rb +14 -1008
  46. data/lib/kward/conversation.rb +31 -2
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +2 -2
  49. data/lib/kward/model/copilot_models.rb +2 -2
  50. data/lib/kward/model/model_info.rb +20 -3
  51. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  52. data/lib/kward/model/payloads.rb +12 -3
  53. data/lib/kward/model/typesafe_client.rb +78 -0
  54. data/lib/kward/pan/server.rb +10 -7
  55. data/lib/kward/permissions/policy.rb +6 -2
  56. data/lib/kward/plugin_registry.rb +2 -659
  57. data/lib/kward/plugins/actions.rb +453 -0
  58. data/lib/kward/plugins/chat_contract.rb +121 -0
  59. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  60. data/lib/kward/plugins/host.rb +232 -0
  61. data/lib/kward/plugins/registry.rb +1190 -0
  62. data/lib/kward/plugins/resources.rb +206 -0
  63. data/lib/kward/plugins/turn_request.rb +36 -0
  64. data/lib/kward/plugins/ui.rb +219 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  66. data/lib/kward/prompt_interface/composer_state.rb +1 -1
  67. data/lib/kward/prompt_interface/editor/controller.rb +1 -1
  68. data/lib/kward/prompt_interface/editor/runner.rb +1 -1
  69. data/lib/kward/prompt_interface/editor/state.rb +1 -1
  70. data/lib/kward/prompt_interface/layout.rb +1 -1
  71. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -1
  72. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  73. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  74. data/lib/kward/prompt_interface/screen.rb +9 -2
  75. data/lib/kward/prompt_interface.rb +60 -11
  76. data/lib/kward/prompts/commands.rb +2 -1
  77. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  78. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +2 -2
  79. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  80. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  81. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +9 -4
  82. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  83. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  84. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  85. data/lib/kward/rpc/server.rb +91 -12
  86. data/lib/kward/rpc/session_manager.rb +147 -44
  87. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  88. data/lib/kward/rpc/tool_metadata.rb +1 -1
  89. data/lib/kward/sandbox/command_runner.rb +1 -1
  90. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  91. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  92. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  93. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  94. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  95. data/lib/kward/{kwsh.rb → shell/kwsh.rb} +3 -3
  96. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +3 -3
  97. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  98. data/lib/kward/skills/trust_store.rb +1 -1
  99. data/lib/kward/tabs/driver.rb +194 -0
  100. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  101. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  102. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  103. data/lib/kward/tools/base.rb +18 -0
  104. data/lib/kward/tools/context_for_task.rb +15 -6
  105. data/lib/kward/tools/edit_file.rb +9 -6
  106. data/lib/kward/tools/git_commit.rb +13 -7
  107. data/lib/kward/tools/list_directory.rb +4 -4
  108. data/lib/kward/tools/plugin_tool.rb +41 -0
  109. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  110. data/lib/kward/tools/read_file.rb +7 -6
  111. data/lib/kward/tools/registry.rb +96 -13
  112. data/lib/kward/tools/run_shell_command.rb +10 -8
  113. data/lib/kward/tools/search/code.rb +1 -1
  114. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  115. data/lib/kward/tools/tool_call.rb +2 -1
  116. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  117. data/lib/kward/tools/workspace_targets.rb +58 -0
  118. data/lib/kward/tools/write_file.rb +9 -6
  119. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  120. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  121. data/lib/kward/transport/contracts.rb +200 -0
  122. data/lib/kward/transport/gateway.rb +79 -35
  123. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  124. data/lib/kward/transport.rb +1 -200
  125. data/lib/kward/version.rb +1 -1
  126. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  127. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  128. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  129. data/templates/default/fulldoc/html/css/kward.css +125 -0
  130. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  131. data/templates/default/fulldoc/html/setup.rb +1 -1
  132. data/templates/default/layout/html/layout.erb +16 -4
  133. metadata +67 -48
  134. data/lib/kward/tab_driver.rb +0 -90
  135. data/templates/default/fulldoc/html/images/kward_workflow.svg +0 -52
  136. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  137. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  138. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  139. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  140. /data/lib/kward/{markdown_code_block.rb → prompt_interface/editor/markdown_code_block.rb} +0 -0
  141. /data/lib/kward/{scratchpad_languages.rb → prompt_interface/editor/scratchpad_languages.rb} +0 -0
  142. /data/lib/kward/{scratchpad_runner.rb → prompt_interface/editor/scratchpad_runner.rb} +0 -0
  143. /data/lib/kward/{detached_run.rb → pty/detached_run.rb} +0 -0
  144. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  145. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  146. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  147. /data/lib/kward/{kwshrc.rb → shell/kwshrc.rb} +0 -0
  148. /data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +0 -0
  149. /data/lib/kward/{ansi.rb → terminal/ansi.rb} +0 -0
  150. /data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +0 -0
  151. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  152. /data/lib/kward/{terminal_text.rb → terminal/text.rb} +0 -0
  153. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  154. /data/lib/kward/{project_files.rb → workspace/files.rb} +0 -0
  155. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -11,7 +11,7 @@ module Kward
11
11
  end
12
12
 
13
13
  def plugin_registry
14
- @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_slash_command_names)
14
+ @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_slash_command_names).tap(&:start!)
15
15
  end
16
16
 
17
17
  def plugin_commands
@@ -30,15 +30,22 @@ module Kward
30
30
  plugin_registry.interactive_command_for(command)
31
31
  end
32
32
 
33
- def reload_plugins(conversation)
33
+ def reload_plugins(conversation, tool_registry: nil)
34
34
  @prompt_templates = nil
35
- @plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
35
+ registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
36
+ @plugin_registry&.reload!
37
+ @plugin_registry = registry.tap(&:start!)
36
38
  @prompt.update_slash_commands(slash_command_entries) if @prompt.respond_to?(:update_slash_commands)
37
39
  conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
38
40
  conversation.refresh_system_message! if conversation.respond_to?(:refresh_system_message!)
41
+ tool_registry&.replace_plugin_tools!(@plugin_registry.tools)
39
42
  runtime_output("Plugins reloaded.")
40
43
  end
41
44
 
45
+ def shutdown_plugins
46
+ @plugin_registry&.shutdown!
47
+ end
48
+
42
49
  def lifecycle_hook_manager(conversation)
43
50
  manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(conversation.workspace_root)).manager
44
51
  plugin_registry.hook_handlers.each do |hook|
@@ -50,7 +57,7 @@ module Kward
50
57
  end
51
58
 
52
59
  def lifecycle_hook_context(conversation)
53
- plugin_context(conversation, "")
60
+ plugin_context(conversation, "", requests: false, tool_requests: true)
54
61
  end
55
62
 
56
63
  def run_lifecycle_hook(name, conversation:, payload: {}, session: @active_session)
@@ -81,7 +88,7 @@ module Kward
81
88
  return [false, nil] unless command
82
89
  return [false, nil] unless prompt_interface? && @prompt.respond_to?(:start_interactive)
83
90
 
84
- context = plugin_context(agent.conversation, argument)
91
+ context = plugin_context(agent.conversation, argument, requests: false)
85
92
  controller = @prompt.start_interactive(title: "/#{name}", rows: command.rows, fps: command.fps)
86
93
  command.handler.call(controller, context)
87
94
  run_interactive_loop
@@ -150,26 +157,88 @@ module Kward
150
157
  PromptCommands.expand(input, templates: prompt_templates, reserved_commands: builtin_slash_command_names)
151
158
  end
152
159
 
153
- def run_plugin_command(name, argument, agent)
160
+ def run_plugin_command_and_turn(name, argument, agent)
161
+ origin_tab = active_tab
162
+ _handled, request = run_busy_local_command_and_requeue(activity: "running") do |cancellation|
163
+ run_plugin_command(name, argument, agent, cancellation: cancellation)
164
+ end
165
+ return [true, nil] unless request
166
+ return [true, nil] if origin_tab && (!@tabs.include?(origin_tab) || !origin_tab.agent.equal?(agent))
167
+
168
+ flush_pending_reasoning_config(conversation: agent.conversation)
169
+ if origin_tab
170
+ session = origin_tab.session
171
+ session.rename(default_session_name(request.to_s)) if session && session.name.to_s.empty?
172
+ submit_tab_input(origin_tab, request, display_input: request.to_s)
173
+ else
174
+ auto_name_active_session(request.to_s)
175
+ pending = run_interactive_turn(agent, request, display_input: request.to_s)
176
+ pending.reverse_each { |input| @pending_inputs.unshift(input) }
177
+ end
178
+ [true, nil]
179
+ rescue Cancellation::CancelledError
180
+ [true, nil]
181
+ rescue StandardError => e
182
+ runtime_output("Error: #{e.message}")
183
+ [true, nil]
184
+ end
185
+
186
+ def run_plugin_command(name, argument, agent, cancellation: nil)
154
187
  command = plugin_command_for(name)
155
188
  return [false, nil] unless command
156
189
 
190
+ cancellation&.raise_if_cancelled!
157
191
  agent.conversation.plugin_registry ||= plugin_registry if agent.conversation.respond_to?(:plugin_registry)
158
- context = plugin_context(agent.conversation, argument)
159
- command.handler.call(argument, context)
160
- [true, nil]
192
+ arguments = command.parse_arguments(argument)
193
+ context = plugin_context(agent.conversation, arguments, cancellation: cancellation, turn_command: command)
194
+ result = command.normalize_result(command.handler.call(arguments, context))
195
+ cancellation&.raise_if_cancelled!
196
+ runtime_output(result.message) if command.typed? && !result.message.to_s.empty?
197
+ [true, context.requested_turn]
198
+ rescue Cancellation::CancelledError
199
+ raise
161
200
  rescue StandardError => e
162
201
  runtime_output("Plugin command /#{name} error: #{e.message}")
163
202
  [true, nil]
164
203
  end
165
204
 
166
- def plugin_context(conversation, args)
205
+ def plugin_context(conversation, args, requests: true, tool_requests: nil, cancellation: nil, turn_command: nil)
206
+ say_callback = lambda { |message| runtime_output(message) }
207
+ tool_requests = requests if tool_requests.nil?
167
208
  PluginRegistry::Context.new(
168
209
  conversation: conversation,
169
210
  args: args,
170
211
  session: @active_session,
171
212
  workspace_root: conversation.workspace_root,
172
- say_callback: lambda { |message| runtime_output(message) }
213
+ say_callback: say_callback,
214
+ cancellation: cancellation,
215
+ ui: plugin_ui(say_callback, requests: requests),
216
+ tool_ui: plugin_ui(say_callback, requests: tool_requests),
217
+ turn_command: turn_command
218
+ )
219
+ end
220
+
221
+ def plugin_ui(say_callback, requests:)
222
+ capabilities = { progress: true, notify: true }
223
+ requester = nil
224
+ if requests && @prompt.respond_to?(:request_plugin_ui)
225
+ capabilities.merge!(question: true, select: true, confirm: true, input: true)
226
+ requester = lambda do |kind, payload, cancellation: nil|
227
+ @prompt.request_plugin_ui(kind, payload, cancellation: cancellation)
228
+ end
229
+ end
230
+ emitter = lambda do |kind, payload|
231
+ case kind
232
+ when :notify
233
+ runtime_output(payload[:message])
234
+ when :progress
235
+ text = payload[:percent] ? "#{payload[:message]} (#{payload[:percent]}%)" : payload[:message]
236
+ runtime_output(text)
237
+ end
238
+ end
239
+ PluginUI.new(
240
+ backend: PluginUI::Backend.new(capabilities: capabilities, requester: requester, emitter: emitter),
241
+ say_callback: say_callback
173
242
  )
174
243
  end
175
244
 
@@ -201,7 +270,7 @@ module Kward
201
270
  return unless conversation
202
271
  return if plugin_registry.transcript_event_handlers.empty?
203
272
 
204
- plugin_registry.notify_transcript_event(event, plugin_context(conversation, ""))
273
+ plugin_registry.notify_transcript_event(event, plugin_context(conversation, "", requests: false))
205
274
  end
206
275
 
207
276
  def notify_plugin_tab_transcript_event(event, driver)
@@ -247,14 +247,16 @@ module Kward
247
247
  end
248
248
 
249
249
  def prompt_footer_renderer
250
- return nil unless plugin_registry.footer_renderer
250
+ return nil unless plugin_registry.status?
251
251
 
252
- lambda do
253
- renderer = plugin_registry.footer_renderer
254
- next "" unless renderer
252
+ lambda do |max_width = nil|
253
+ next "" unless plugin_registry.status?
255
254
 
256
255
  context = plugin_context(current_footer_conversation, "")
257
- renderer.call(context).to_s
256
+ segments = plugin_registry.status_segments(context)
257
+ plugin_registry.compose_status(segments, max_width: max_width) do |text|
258
+ TerminalText.width(ANSI.strip(text.to_s))
259
+ end
258
260
  rescue StandardError => e
259
261
  emit_warning "Warning: Kward plugin footer error: #{e.message}"
260
262
  ""
@@ -271,6 +273,8 @@ module Kward
271
273
  parts = []
272
274
  git = composer_git_branch_text
273
275
  parts << git if git
276
+ merge = composer_worktree_merge_text
277
+ parts << merge if merge
274
278
  diff = composer_session_diff_text
275
279
  parts << diff if diff
276
280
  usage = composer_context_usage(provider, model)
@@ -279,6 +283,22 @@ module Kward
279
283
  parts.join(" · ")
280
284
  end
281
285
 
286
+ def composer_worktree_merge_text
287
+ return nil unless respond_to?(:active_tab, true) && respond_to?(:worktree_binding_for, true)
288
+
289
+ binding = worktree_binding_for(active_tab)
290
+ return nil unless binding&.active?
291
+ return nil unless git_worktree_manager.merge_in_progress?(binding.origin_root)
292
+
293
+ conflicts = git_worktree_manager.merge_conflict_marker_paths(binding.origin_root, cached: false)
294
+ target = git_worktree_manager.current_branch(binding.origin_root)
295
+ count = conflicts.length
296
+ detail = count.zero? ? "ready" : "#{count} conflict#{count == 1 ? "" : "s"}"
297
+ ANSI.colorize("merge→#{target.empty? ? "detached" : target} (#{detail})", :red, enabled: @color_enabled)
298
+ rescue GitWorktreeManager::Error
299
+ nil
300
+ end
301
+
282
302
  def composer_session_diff_text
283
303
  return nil if @session_diff.nil? || @session_diff.empty?
284
304
 
@@ -224,6 +224,9 @@ module Kward
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
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
@@ -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
@@ -320,8 +326,13 @@ module Kward
320
326
  @prompt.say(intro_message)
321
327
  end
322
328
  origin_tab = active_tab if respond_to?(:active_tab, true)
323
- result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd) do |sink, completed_result|
324
- transcript_output = record_completed_pty_output(sink, completed_result, tab: origin_tab)
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
+ )
325
336
  yield(transcript_output, completed_result) if block_given?
326
337
  end
327
338
  refresh_composer_status
@@ -337,7 +348,13 @@ module Kward
337
348
  with_interactive_terminal_handoff do |input, sink|
338
349
  tab_action_handler = terminal_handoff_tab_action_handler
339
350
  on_detach = if origin_tab
340
- -> { BufferedPtyOutputSink.new(max_capture_bytes: Kward::CLI::Tabs::TRANSIENT_SHELL_OUTPUT_LIMIT) }
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
341
358
  end
342
359
  result = if shell.respond_to?(:run_interactive)
343
360
  shell.run_interactive(command, input: input, sink: sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
@@ -345,7 +362,7 @@ module Kward
345
362
  runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, sink: sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
346
363
  end
347
364
  if result&.background
348
- register_background_tab_run(origin_tab, result.background, &on_complete)
365
+ register_background_tab_run(origin_tab, result.background, output_kind: :pty, &on_complete)
349
366
  else
350
367
  on_complete.call(sink, result) if on_complete
351
368
  end
@@ -404,7 +421,76 @@ module Kward
404
421
  end
405
422
  end
406
423
 
407
- def record_completed_pty_output(sink, result, tab: nil)
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)
408
494
  classified_output = sink.respond_to?(:transcript_safe?)
409
495
  return if classified_output && !sink.transcript_safe?
410
496
 
@@ -412,20 +498,13 @@ module Kward
412
498
  allow_input = classified_output &&
413
499
  sink.respond_to?(:pre_input_capture_only?) &&
414
500
  sink.pre_input_capture_only?
415
- transcript_output = terminal_transcript_output(
501
+ terminal_transcript_output(
416
502
  output,
417
503
  result,
418
504
  truncated: sink&.truncated? || false,
419
505
  allow_input: allow_input,
420
506
  normalize_line_controls: classified_output
421
507
  )
422
- return unless transcript_output
423
-
424
- record_tab_transient_shell_output(transcript_output, render: false, tab: tab)
425
- if @prompt.respond_to?(:record_transient_terminal_output) && (!tab || tab == active_tab)
426
- @prompt.record_transient_terminal_output(transcript_output, render: false)
427
- end
428
- transcript_output
429
508
  end
430
509
 
431
510
  def terminal_transcript_output(
@@ -602,6 +681,7 @@ module Kward
602
681
  chunks = Queue.new
603
682
  queued_inputs = []
604
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)
605
685
  detached_sink = nil
606
686
  result = nil
607
687
  error = nil
@@ -610,6 +690,7 @@ module Kward
610
690
  if detached_sink
611
691
  detached_sink.write(chunk)
612
692
  else
693
+ streamed_output.write(chunk)
613
694
  chunks << chunk
614
695
  end
615
696
  end
@@ -626,17 +707,18 @@ module Kward
626
707
  elsif poll_result.is_a?(Hash) && poll_result[:tab_action]
627
708
  (@pending_inputs ||= []).unshift(poll_result)
628
709
  if origin_tab
629
- detached_sink = BufferedPtyOutputSink.new(max_capture_bytes: Kward::CLI::Tabs::TRANSIENT_SHELL_OUTPUT_LIMIT)
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
+ )
630
715
  background = DetachedRun.new(sink: detached_sink, canceler: -> { cancellation.cancel! }) do
631
716
  worker.join
632
717
  raise error if error
633
718
 
634
719
  result
635
720
  end
636
- register_background_tab_run(origin_tab, background) do |_sink, completed_result|
637
- record_tab_transient_shell_output(completed_result.output, render: false, tab: origin_tab)
638
- @prompt.say(completed_result.output) if origin_tab == active_tab && completed_result.output.to_s != ""
639
- end
721
+ register_background_tab_run(origin_tab, background, output_kind: :streamed)
640
722
  return Kwsh::Result.new(output: "", exit_status: 0, streamed: true)
641
723
  end
642
724
  cancellation.cancel!
@@ -737,44 +819,56 @@ module Kward
737
819
  queued_inputs = []
738
820
  result = nil
739
821
  error = nil
740
- 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)
741
826
 
742
827
  worker = Thread.new do
743
- result = yield
828
+ Thread.current[:kward_local_busy_tab] = origin_tab
829
+ result = yield(cancellation)
744
830
  rescue StandardError => e
745
831
  error = e
832
+ ensure
833
+ Thread.current[:kward_local_busy_tab] = nil
746
834
  end
747
835
 
748
836
  while worker.alive?
749
837
  poll_result = collect_queued_input(queued_inputs)
838
+ if poll_result == PromptInterface::CANCEL_INPUT && !cancelled
839
+ cancelled = true
840
+ cancellation.cancel!
841
+ end
750
842
  handle_busy_local_tab_action(poll_result, activity: activity) if busy_local_tab_action?(poll_result)
751
843
  sleep 0.02
752
844
  end
753
845
  worker.join
754
846
  drain_queued_input(queued_inputs)
755
- 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)
756
850
 
757
851
  [result, queued_inputs]
758
852
  ensure
759
- finish_local_busy_command(activity)
853
+ finish_local_busy_command(activity, tab: origin_tab)
760
854
  end
761
855
 
762
856
  def run_busy_local_command_and_requeue(activity: "loading")
763
857
  return yield unless prompt_interface?
764
858
 
765
- result, queued_inputs = run_busy_local_command(activity: activity) { yield }
859
+ result, queued_inputs = run_busy_local_command(activity: activity) { |cancellation| yield(cancellation) }
766
860
  queued_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
767
861
  result
768
862
  end
769
863
 
770
- def begin_local_busy_command(activity)
771
- 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
772
866
  @prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
773
867
  update_prompt_tabs if respond_to?(:update_prompt_tabs, true)
774
868
  end
775
869
 
776
- def finish_local_busy_command(activity)
777
- tab = local_busy_tab(activity)
870
+ def finish_local_busy_command(activity, tab: nil)
871
+ tab ||= local_busy_tab(activity)
778
872
  tab.local_busy_activity = nil if tab
779
873
  update_prompt_tabs if respond_to?(:update_prompt_tabs, true)
780
874
  @prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input) && (!active_tab || active_tab == tab)
@@ -392,13 +392,15 @@ module Kward
392
392
  end
393
393
 
394
394
  def reload_active_session(session_store)
395
+ workspace_root = current_workspace_root
396
+ worktree = worktree_binding_for(active_tab)
395
397
  @active_session, conversation = session_store.load(
396
398
  @active_session.path,
397
- workspace: configured_workspace(root: session_store.cwd),
399
+ workspace: configured_workspace(root: workspace_root, strict: worktree&.active? == true),
398
400
  provider: current_model_provider,
399
401
  model: current_model_id,
400
402
  reasoning_effort: current_reasoning_effort,
401
- project_skill_paths: project_skill_paths_for(session_store.cwd) || []
403
+ project_skill_paths: project_skill_paths_for(workspace_root) || []
402
404
  )
403
405
  reset_session_diff(@active_session.path)
404
406
  track_session(@active_session)
@@ -415,7 +417,7 @@ module Kward
415
417
  SessionTreeRenderer.new(roots: roots, current_leaf_id: current_leaf_id).items
416
418
  end
417
419
 
418
- def rename_session(argument, require_name: false)
420
+ def rename_session(argument, require_name: false, announce: true)
419
421
  unless @active_session
420
422
  runtime_output("No active persisted session.")
421
423
  return
@@ -430,8 +432,10 @@ module Kward
430
432
  old_name = @active_session.name
431
433
  @active_session.rename(name)
432
434
  run_lifecycle_hook("session_rename", conversation: new_conversation(workspace_root: current_workspace_root), session: @active_session, payload: { old_name: old_name, new_name: @active_session.name })
433
- label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
434
- runtime_output(label)
435
+ if announce
436
+ label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
437
+ runtime_output(label)
438
+ end
435
439
  end
436
440
 
437
441
  def clone_session(session_store, agent)