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
@@ -8,11 +8,32 @@ module Kward
8
8
 
9
9
  # Writes the doctor output for the terminal CLI flow.
10
10
  def print_doctor
11
- lines = ["#{colored("Kward Doctor", :green, :bold)}", ""]
12
- doctor_checks.each do |check|
13
- lines << "#{doctor_mark(check.fetch(:status))} #{check.fetch(:label)}: #{check.fetch(:message)}"
11
+ checks = doctor_checks
12
+ core_checks, optional_checks = checks.partition { |check| !check[:optional] }
13
+ lines = [colored("Kward Doctor", :green, :bold), "", colored("Core checks", :blue, :bold)]
14
+ lines.concat(doctor_check_lines(core_checks))
15
+ if optional_checks.any?
16
+ lines << ""
17
+ lines << colored("Optional", :blue, :bold)
18
+ lines.concat(doctor_check_lines(optional_checks))
14
19
  end
20
+ lines << ""
21
+ lines << doctor_summary(checks)
15
22
  @prompt.say lines.join("\n")
23
+ checks.none? { |check| check.fetch(:status) == :error && !check[:optional] }
24
+ end
25
+
26
+ def doctor_check_lines(checks)
27
+ checks.map { |check| "#{doctor_mark(check.fetch(:status))} #{check.fetch(:label)}: #{check.fetch(:message)}" }
28
+ end
29
+
30
+ def doctor_summary(checks)
31
+ errors = checks.count { |check| check.fetch(:status) == :error && !check[:optional] }
32
+ warnings = checks.count { |check| check.fetch(:status) == :warning && !check[:optional] }
33
+ return "Kward needs attention: #{errors} core check#{errors == 1 ? "" : "s"} failed." if errors.positive?
34
+ return "Kward is ready with #{warnings} warning#{warnings == 1 ? "" : "s"}." if warnings.positive?
35
+
36
+ "Kward is ready."
16
37
  end
17
38
 
18
39
  def doctor_checks
@@ -108,29 +129,28 @@ module Kward
108
129
  { status: :error, label: "Local endpoint", message: "invalid URL: #{url}" }
109
130
  end
110
131
 
111
- def doctor_auth_check(config)
112
- openai_auth = OpenAIOAuth.default_auth_path
113
- github_auth = GithubOAuth.default_auth_path
114
- has_openrouter = !config.to_h["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?
132
+ def doctor_auth_check(_config)
115
133
  local_provider = @client.respond_to?(:current_provider) && @client.current_provider == "Local"
116
- paths = []
117
- paths << "OpenAI OAuth" if File.exist?(openai_auth)
118
- paths << "GitHub OAuth" if File.exist?(github_auth)
119
- paths << "OpenRouter API key" if has_openrouter
120
- paths << "Local endpoint (no authentication required)" if local_provider
121
- return { status: :ok, label: "Auth", message: paths.join(", ") } if paths.any?
134
+ configured = auth_credentials.select { |credential| credential.fetch(:configured) }.map { |credential| credential.fetch(:label) }
135
+ configured << "Local endpoint (no authentication required)" if local_provider
136
+ return { status: :ok, label: "Auth", message: configured.join(", ") } if configured.any?
122
137
 
123
138
  { status: :warning, label: "Auth", message: "no saved credentials found; run `kward login`" }
139
+ rescue ConfigFiles::ConfigError
140
+ { status: :warning, label: "Auth", message: "skipped because config is invalid" }
124
141
  end
125
142
 
126
143
  def doctor_pan_check(config_result)
127
- return { status: :warning, label: "Pan mode", message: "skipped because config is invalid" } if config_result.is_a?(ConfigFiles::ConfigError)
144
+ return { status: :optional, label: "Pan mode", message: "skipped because config is invalid", optional: true } if config_result.is_a?(ConfigFiles::ConfigError)
128
145
 
129
146
  pan = config_result.to_h["pan_mode"] || {}
130
- if !pan["username"].to_s.empty? && !pan["password"].to_s.empty?
131
- { status: :ok, label: "Pan mode", message: "credentials configured" }
147
+ environment_password = ENV["KWARD_PAN_PASSWORD"].to_s
148
+ password = environment_password.empty? ? pan["password"].to_s : environment_password
149
+ if !pan["username"].to_s.empty? && !password.empty?
150
+ source = environment_password.empty? ? "config" : "environment"
151
+ { status: :ok, label: "Pan mode", message: "credentials configured (password from #{source})", optional: true }
132
152
  else
133
- { status: :warning, label: "Pan mode", message: "username/password not configured" }
153
+ { status: :optional, label: "Pan mode", message: "not configured", optional: true }
134
154
  end
135
155
  end
136
156
 
@@ -140,6 +160,8 @@ module Kward
140
160
  colored("✓", :green, :bold)
141
161
  when :warning
142
162
  colored("!", :yellow, :bold)
163
+ when :optional
164
+ colored("•", :gray, :bold)
143
165
  else
144
166
  colored("✗", :red, :bold)
145
167
  end
@@ -9,7 +9,9 @@ module Kward
9
9
  module HookCommands
10
10
  private
11
11
 
12
- def handle_hooks_command(argument)
12
+ def handle_hooks_command(argument, command_line: false)
13
+ previous_output_mode = @hook_command_line_output
14
+ @hook_command_line_output = command_line
13
15
  subcommand, rest = argument.to_s.strip.split(/\s+/, 2)
14
16
  subcommand = "list" if subcommand.to_s.empty?
15
17
 
@@ -27,8 +29,16 @@ module Kward
27
29
  when "untrust"
28
30
  untrust_workspace_hooks
29
31
  else
30
- runtime_output("Usage: /hooks [list|events|logs|doctor|trust|untrust]")
32
+ raise ArgumentError, command_usage("hooks") if command_line
33
+
34
+ hook_command_output("Usage: /hooks [list|events|logs|doctor|trust|untrust]")
31
35
  end
36
+ ensure
37
+ @hook_command_line_output = previous_output_mode
38
+ end
39
+
40
+ def hook_command_output(text)
41
+ @hook_command_line_output ? @prompt.say(text) : runtime_output(text)
32
42
  end
33
43
 
34
44
  def print_hooks_list
@@ -46,7 +56,7 @@ module Kward
46
56
  lines << "- #{details.join(' ')}"
47
57
  end
48
58
  end
49
- runtime_output(lines.join("\n"))
59
+ hook_command_output(lines.join("\n"))
50
60
  end
51
61
 
52
62
  def print_hooks_events
@@ -57,7 +67,7 @@ module Kward
57
67
  suffix = fields.empty? ? "" : " modifies=#{fields.join(',')}"
58
68
  lines << "- #{event_name} failure_policy=#{Hooks::Catalog.failure_policy(event_name)}#{suffix}"
59
69
  end
60
- runtime_output(lines.join("\n"))
70
+ hook_command_output(lines.join("\n"))
61
71
  end
62
72
 
63
73
  def print_hooks_logs(argument)
@@ -65,7 +75,7 @@ module Kward
65
75
  count = 20 unless count.positive?
66
76
  path = hooks_log_path
67
77
  unless File.file?(path)
68
- runtime_output("No lifecycle hook audit log found at #{path}.")
78
+ hook_command_output("No lifecycle hook audit log found at #{path}.")
69
79
  return
70
80
  end
71
81
 
@@ -75,7 +85,7 @@ module Kward
75
85
  nil
76
86
  end
77
87
  if records.empty?
78
- runtime_output("No readable lifecycle hook audit records found at #{path}.")
88
+ hook_command_output("No readable lifecycle hook audit records found at #{path}.")
79
89
  return
80
90
  end
81
91
 
@@ -83,7 +93,7 @@ module Kward
83
93
  records.each do |record|
84
94
  lines << format_hook_log_record(record)
85
95
  end
86
- runtime_output(lines.join("\n"))
96
+ hook_command_output(lines.join("\n"))
87
97
  end
88
98
 
89
99
  def print_hooks_doctor
@@ -105,7 +115,7 @@ module Kward
105
115
  rescue StandardError => e
106
116
  lines << "Error: #{e.message}"
107
117
  end
108
- runtime_output(lines.join("\n"))
118
+ hook_command_output(lines.join("\n"))
109
119
  end
110
120
 
111
121
  def configured_hook_entries
@@ -226,16 +236,16 @@ module Kward
226
236
 
227
237
  def trust_workspace_hooks
228
238
  ConfigFiles.trust_workspace_hooks!(current_workspace_root)
229
- runtime_output("Trusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
239
+ hook_command_output("Trusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
230
240
  rescue StandardError => e
231
- runtime_output("Workspace hook trust error: #{e.message}")
241
+ hook_command_output("Workspace hook trust error: #{e.message}")
232
242
  end
233
243
 
234
244
  def untrust_workspace_hooks
235
245
  ConfigFiles.untrust_workspace_hooks!(current_workspace_root)
236
- runtime_output("Untrusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
246
+ hook_command_output("Untrusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
237
247
  rescue StandardError => e
238
- runtime_output("Workspace hook trust error: #{e.message}")
248
+ hook_command_output("Workspace hook trust error: #{e.message}")
239
249
  end
240
250
 
241
251
  def hooks_log_path
@@ -47,9 +47,10 @@ module Kward
47
47
  def build_editor_prompt_agent(active_agent, context, registry)
48
48
  active_conversation = active_agent.conversation
49
49
  config = safely_read_config.to_h
50
- provider = active_conversation.provider || current_model_provider
51
- model = ConfigFiles.editor_agent_model(config) || active_conversation.model || current_model_id
52
- reasoning = ConfigFiles.editor_agent_reasoning_effort(config) || active_conversation.reasoning_effort || current_reasoning_effort
50
+ configured_provider = ConfigFiles.editor_agent_provider(config)
51
+ provider = configured_provider || active_conversation.provider || current_model_provider
52
+ model = ConfigFiles.editor_agent_model(config) || (configured_provider ? nil : active_conversation.model || current_model_id)
53
+ reasoning = ConfigFiles.editor_agent_reasoning_effort(config) || (configured_provider ? nil : active_conversation.reasoning_effort || current_reasoning_effort)
53
54
  conversation = Conversation.new(
54
55
  system_message: EditorPrompt.system_message,
55
56
  workspace_root: context[:workspace_root] || active_conversation.workspace_root,
@@ -61,6 +62,7 @@ module Kward
61
62
  client: @client,
62
63
  tool_registry: registry,
63
64
  conversation: conversation,
65
+ strict_provider: !configured_provider.nil?,
64
66
  warning_sink: ConfigFiles.warning_sink
65
67
  )
66
68
  end
@@ -76,9 +78,11 @@ module Kward
76
78
  event_queue = Queue.new
77
79
  stream_state = {
78
80
  streamed: false,
81
+ assistant_arrived: false,
79
82
  last_flush: monotonic_now,
80
83
  stream_block_open: false,
81
84
  markdown_streams: {},
85
+ tool_elapsed_ms: {},
82
86
  defer_assistant_streaming: defer_assistant_streaming?(agent)
83
87
  }
84
88
  markdown_chunks = []
@@ -126,19 +130,29 @@ module Kward
126
130
  if busy_replacement_agent? || suppress_transcript
127
131
  discard_interactive_events(event_queue, markdown_chunks, stream_state, force: true)
128
132
  else
129
- drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
133
+ drain_remaining_interactive_events(event_queue, markdown_chunks, stream_state, agent)
130
134
  end
131
135
  completed = !cancelled && !busy_replacement_agent? && !error
132
136
  on_complete&.call(successful: completed, cancelled: cancelled, error: error, answer: answer)
133
137
  raise error if error && !error.is_a?(Cancellation::CancelledError) && !busy_replacement_agent?
134
138
 
135
139
  @prompt.commit_editor_prompt(editor_prompt_session) if editor_prompt_session && completed
136
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || cancelled || busy_replacement_agent? || stream_state[:streamed] || answer.to_s.empty?
140
+ @prompt.say("\n#{colored(transcript_marker("Assistant"), :activity, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || cancelled || busy_replacement_agent? || stream_state[:streamed] || answer.to_s.empty?
137
141
  persist_memory_state(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
138
142
  auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript && queued_inputs.empty? && !cancelled
139
143
  queued_inputs
140
144
  ensure
141
145
  @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
146
+ if @prompt.respond_to?(:show_completion) && !busy_replacement_agent?
147
+ completion = if cancelled
148
+ :cancelled
149
+ elsif error
150
+ :failed
151
+ elsif completed
152
+ :success
153
+ end
154
+ @prompt.show_completion(completion) if completion
155
+ end
142
156
  if editor_prompt_session && @prompt.respond_to?(:resume_editor_for_agent)
143
157
  status = if cancelled
144
158
  "Agent request cancelled"
@@ -174,6 +188,14 @@ module Kward
174
188
  flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
175
189
  end
176
190
 
191
+ def drain_remaining_interactive_events(event_queue, markdown_chunks, stream_state, agent)
192
+ until event_queue.empty?
193
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
194
+ sleep STREAM_RENDER_INTERVAL unless event_queue.empty?
195
+ end
196
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
197
+ end
198
+
177
199
  def discard_interactive_events(event_queue, markdown_chunks, stream_state, force: false)
178
200
  drained = 0
179
201
  loop do
@@ -193,12 +215,19 @@ module Kward
193
215
  case event
194
216
  when Events::ReasoningDelta
195
217
  stream_state[:streamed] = true
218
+ update_busy_activity("reasoning")
196
219
  append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
197
220
  when Events::ReasoningBoundary
198
221
  stream_state[:streamed] = true
199
222
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
223
+ update_busy_activity("thinking")
200
224
  when Events::AssistantDelta
201
225
  stream_state[:streamed] = true
226
+ unless stream_state[:assistant_arrived]
227
+ @prompt.show_response_arrival if @prompt.respond_to?(:show_response_arrival)
228
+ stream_state[:assistant_arrived] = true
229
+ end
230
+ update_busy_activity("responding")
202
231
  append_markdown_delta(markdown_chunks, "Assistant", event.delta)
203
232
  when Events::Steering
204
233
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
@@ -208,18 +237,30 @@ module Kward
208
237
  when Events::Retry
209
238
  stream_state[:streamed] = true
210
239
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
240
+ update_busy_activity("retrying")
211
241
  print_retry(event)
212
242
  when Events::ToolCall
213
243
  stream_state[:streamed] = true
214
244
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
245
+ update_busy_activity(tool_activity(event.tool_call))
246
+ when Events::ToolUpdate
247
+ key = tool_call_id(event.tool_call) || event.tool_call.object_id
248
+ stream_state[:tool_elapsed_ms][key] = event.elapsed_ms
215
249
  when Events::ToolResult
216
250
  stream_state[:streamed] = true
217
251
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
218
252
  update_session_diff(event.content, tool_call: event.tool_call)
219
- print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
253
+ key = tool_call_id(event.tool_call) || event.tool_call.object_id
254
+ elapsed_ms = stream_state[:tool_elapsed_ms].delete(key)
255
+ print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT, elapsed_ms: elapsed_ms)
256
+ update_busy_activity("thinking")
220
257
  end
221
258
  end
222
259
 
260
+ def update_busy_activity(activity)
261
+ @prompt.update_busy_activity(activity) if @prompt.respond_to?(:update_busy_activity)
262
+ end
263
+
223
264
  def flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: false)
224
265
  if force
225
266
  finish_interactive_markdown_deltas(markdown_chunks, stream_state)
@@ -347,7 +388,7 @@ module Kward
347
388
  streamed = true if !suppress_transcript && render_blocking_turn_event(event, markdown_chunks, tool_line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
348
389
  end
349
390
  flush_markdown_deltas(markdown_chunks) if streamed
350
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || streamed || answer.to_s.empty?
391
+ @prompt.say("\n#{colored(transcript_marker("Assistant"), :activity, :bold)} #{render_markdown_transcript(answer)}\n") unless suppress_transcript || streamed || answer.to_s.empty?
351
392
  persist_memory_state(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
352
393
  auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && !suppress_transcript
353
394
  on_complete&.call(successful: true, cancelled: false, error: nil, answer: answer)
@@ -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)
@@ -7,20 +7,20 @@ module Kward
7
7
  argument = Array(arguments).join(" ").strip.downcase
8
8
  _workspace_root, candidates, coordinator = project_skill_trust_coordinator
9
9
  if candidates.empty? && argument != "untrust"
10
- @prompt.say("No project skills found in the current workspace.")
10
+ project_skills_cli_output("No project skills found in the current workspace.")
11
11
  return
12
12
  end
13
13
 
14
14
  case argument
15
15
  when "", "status"
16
16
  lines = candidates.empty? ? ["No project skills found in the current workspace."] : candidates.map { |candidate| "#{relative_workspace_path(candidate.path)}: #{coordinator.decision(candidate) || "needs review"}" }
17
- @prompt.say(lines.join("\n"))
17
+ project_skills_cli_output(lines.join("\n"))
18
18
  when "trust"
19
19
  coordinator.record!(candidates, "allow")
20
- @prompt.say("Project skills trusted for the current skill snapshots.")
20
+ project_skills_cli_output("Project skills trusted for the current skill snapshots.")
21
21
  when "untrust"
22
22
  coordinator.remove_workspace!
23
- @prompt.say("Project skill trust removed for this workspace.")
23
+ project_skills_cli_output("Project skill trust removed for this workspace.")
24
24
  when "review"
25
25
  review_project_skills(candidates)
26
26
  else
@@ -43,6 +43,10 @@ module Kward
43
43
 
44
44
  private
45
45
 
46
+ def project_skills_cli_output(message)
47
+ @prompt.say("#{colored("Project skills", :green, :bold)}\n\n#{message}")
48
+ end
49
+
46
50
  def project_skill_trust_coordinator
47
51
  workspace_root = current_workspace_root
48
52
  candidates = ConfigFiles.project_skill_candidates(workspace_root: workspace_root)
@@ -45,6 +45,7 @@ module Kward
45
45
  editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers },
46
46
  diff_view: ConfigFiles.diff_view,
47
47
  diff_view_source: -> { ConfigFiles.diff_view },
48
+ editor_runners_source: -> { ConfigFiles.editor_runners },
48
49
  redraw_handler: method(:redraw_interactive_prompt)
49
50
  )
50
51
  if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
@@ -99,6 +100,13 @@ module Kward
99
100
  @prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
100
101
  end
101
102
 
103
+ def open_editor_for_agent(path, base_dir:, allow_new:)
104
+ return false unless prompt_interface?
105
+ return false unless @prompt.respond_to?(:edit_file)
106
+
107
+ @prompt.edit_file(path, base_dir: base_dir, allow_new: allow_new)
108
+ end
109
+
102
110
  def emit_warning(message)
103
111
  sink = ConfigFiles.warning_sink
104
112
  sink ? sink.call(message) : warn(message)
@@ -129,6 +137,7 @@ module Kward
129
137
  [
130
138
  startup_status_line(refresh_update_check: refresh_update_check),
131
139
  *startup_update_notice_lines,
140
+ *startup_authentication_notice_lines,
132
141
  "",
133
142
  startup_info_line("Workspace", startup_workspace_label),
134
143
  startup_info_line("Branch", startup_branch_value),
@@ -198,6 +207,24 @@ module Kward
198
207
  @startup_update_notice = @startup_update_check.notice(refresh: refresh)
199
208
  end
200
209
 
210
+ def startup_authentication_notice_lines
211
+ return [] unless startup_authentication_required?
212
+
213
+ [
214
+ ANSI.colorize(" No model provider is connected.", :yellow, enabled: @color_enabled),
215
+ " Run /login to sign in, or /model to configure a local server."
216
+ ]
217
+ end
218
+
219
+ def startup_authentication_required?
220
+ return false unless @client.is_a?(Client)
221
+ return false if @client.current_provider == "Local"
222
+
223
+ auth_credentials.none? { |credential| credential.fetch(:configured) }
224
+ rescue StandardError
225
+ false
226
+ end
227
+
201
228
  def startup_info_line(label, value)
202
229
  "#{ANSI.colorize(label.ljust(12), :gray, enabled: @color_enabled)}#{ANSI.colorize(value, :cyan, enabled: @color_enabled)}"
203
230
  end
@@ -220,14 +247,16 @@ module Kward
220
247
  end
221
248
 
222
249
  def prompt_footer_renderer
223
- return nil unless plugin_registry.footer_renderer
250
+ return nil unless plugin_registry.status?
224
251
 
225
- lambda do
226
- renderer = plugin_registry.footer_renderer
227
- next "" unless renderer
252
+ lambda do |max_width = nil|
253
+ next "" unless plugin_registry.status?
228
254
 
229
255
  context = plugin_context(current_footer_conversation, "")
230
- 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
231
260
  rescue StandardError => e
232
261
  emit_warning "Warning: Kward plugin footer error: #{e.message}"
233
262
  ""
@@ -244,6 +273,8 @@ module Kward
244
273
  parts = []
245
274
  git = composer_git_branch_text
246
275
  parts << git if git
276
+ merge = composer_worktree_merge_text
277
+ parts << merge if merge
247
278
  diff = composer_session_diff_text
248
279
  parts << diff if diff
249
280
  usage = composer_context_usage(provider, model)
@@ -252,6 +283,22 @@ module Kward
252
283
  parts.join(" · ")
253
284
  end
254
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
+
255
302
  def composer_session_diff_text
256
303
  return nil if @session_diff.nil? || @session_diff.empty?
257
304