kward 0.74.0 → 0.76.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +75 -10
  7. data/doc/extensibility.md +6 -19
  8. data/doc/getting-started.md +1 -1
  9. data/doc/lifecycle-hooks.md +424 -0
  10. data/doc/memory.md +1 -1
  11. data/doc/personas.md +2 -2
  12. data/doc/plugins.md +2 -1
  13. data/doc/releasing.md +7 -15
  14. data/doc/rpc.md +111 -16
  15. data/doc/session-management.md +9 -9
  16. data/doc/skills.md +199 -0
  17. data/doc/usage.md +7 -7
  18. data/lib/kward/agent.rb +77 -22
  19. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  20. data/lib/kward/auth/oauth_helpers.rb +105 -0
  21. data/lib/kward/auth/openai_oauth.rb +4 -86
  22. data/lib/kward/cli/commands.rb +9 -2
  23. data/lib/kward/cli/compaction.rb +7 -0
  24. data/lib/kward/cli/git.rb +30 -3
  25. data/lib/kward/cli/hook_commands.rb +258 -0
  26. data/lib/kward/cli/interactive_turn.rb +3 -0
  27. data/lib/kward/cli/memory_commands.rb +1 -5
  28. data/lib/kward/cli/plugins.rb +41 -1
  29. data/lib/kward/cli/prompt_interface.rb +28 -7
  30. data/lib/kward/cli/rendering.rb +8 -4
  31. data/lib/kward/cli/runtime_helpers.rb +44 -5
  32. data/lib/kward/cli/sessions.rb +13 -0
  33. data/lib/kward/cli/settings.rb +226 -152
  34. data/lib/kward/cli/slash_commands.rb +81 -42
  35. data/lib/kward/cli/tabs.rb +26 -6
  36. data/lib/kward/cli.rb +26 -3
  37. data/lib/kward/cli_transcript_formatter.rb +21 -18
  38. data/lib/kward/compaction/token_estimator.rb +112 -0
  39. data/lib/kward/compactor.rb +42 -116
  40. data/lib/kward/config_files.rb +120 -25
  41. data/lib/kward/deep_copy.rb +42 -0
  42. data/lib/kward/diff_view_mode.rb +36 -0
  43. data/lib/kward/events.rb +2 -0
  44. data/lib/kward/export_path.rb +2 -6
  45. data/lib/kward/frontmatter.rb +43 -0
  46. data/lib/kward/hooks/audit_log.rb +121 -0
  47. data/lib/kward/hooks/catalog.rb +110 -0
  48. data/lib/kward/hooks/command_handler.rb +66 -0
  49. data/lib/kward/hooks/config_loader.rb +104 -0
  50. data/lib/kward/hooks/decision.rb +102 -0
  51. data/lib/kward/hooks/event.rb +60 -0
  52. data/lib/kward/hooks/http_handler.rb +72 -0
  53. data/lib/kward/hooks/manager.rb +158 -0
  54. data/lib/kward/hooks/matcher.rb +75 -0
  55. data/lib/kward/hooks.rb +16 -0
  56. data/lib/kward/markdown_transcript.rb +4 -3
  57. data/lib/kward/mcp/client.rb +1 -1
  58. data/lib/kward/mcp/server_config.rb +7 -3
  59. data/lib/kward/mcp/stdio_transport.rb +7 -1
  60. data/lib/kward/memory/turn_context.rb +19 -0
  61. data/lib/kward/message_access.rb +2 -2
  62. data/lib/kward/message_text.rb +19 -0
  63. data/lib/kward/model/client.rb +23 -52
  64. data/lib/kward/model/context_usage.rb +10 -4
  65. data/lib/kward/model/copilot_models.rb +44 -0
  66. data/lib/kward/model/model_info.rb +38 -5
  67. data/lib/kward/model/payloads.rb +15 -17
  68. data/lib/kward/model/stream_parser.rb +183 -18
  69. data/lib/kward/pan/index.html.erb +1 -0
  70. data/lib/kward/pan/server.rb +53 -2
  71. data/lib/kward/path_guard.rb +13 -0
  72. data/lib/kward/plugin_registry.rb +88 -25
  73. data/lib/kward/prompt_interface/editor/controller.rb +60 -1
  74. data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
  75. data/lib/kward/prompt_interface/editor/state.rb +5 -1
  76. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  77. data/lib/kward/prompt_interface/transcript_renderer.rb +1 -1
  78. data/lib/kward/prompt_interface.rb +4 -1
  79. data/lib/kward/prompts/commands.rb +3 -3
  80. data/lib/kward/prompts.rb +7 -7
  81. data/lib/kward/rpc/mcp_status.rb +50 -0
  82. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  83. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  84. data/lib/kward/rpc/server.rb +95 -15
  85. data/lib/kward/rpc/session_manager.rb +260 -33
  86. data/lib/kward/rpc/session_metrics.rb +1 -1
  87. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  88. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  89. data/lib/kward/rpc/transcript_normalizer.rb +19 -20
  90. data/lib/kward/rpc/turn_context.rb +109 -0
  91. data/lib/kward/session_store.rb +6 -20
  92. data/lib/kward/session_tree_nodes.rb +98 -18
  93. data/lib/kward/session_tree_renderer.rb +6 -61
  94. data/lib/kward/skills/registry.rb +109 -17
  95. data/lib/kward/tools/mcp_tool.rb +4 -0
  96. data/lib/kward/tools/read_skill.rb +9 -4
  97. data/lib/kward/tools/registry.rb +266 -8
  98. data/lib/kward/tools/search/code.rb +4 -22
  99. data/lib/kward/tools/search/web.rb +28 -2
  100. data/lib/kward/update_check.rb +122 -0
  101. data/lib/kward/version.rb +1 -1
  102. data/lib/kward/workers/manager.rb +43 -3
  103. data/lib/kward/workspace.rb +3 -1
  104. data/templates/default/fulldoc/html/css/kward.css +22 -0
  105. data/templates/default/fulldoc/html/js/kward.js +2 -0
  106. data/templates/default/kward_navigation.rb +4 -2
  107. data/templates/default/layout/html/setup.rb +2 -0
  108. metadata +25 -1
data/lib/kward/cli/git.rb CHANGED
@@ -14,6 +14,8 @@ module Kward
14
14
  return
15
15
  end
16
16
 
17
+ previous_git_hook_conversation = @git_hook_conversation
18
+ @git_hook_conversation = agent.conversation
17
19
  root = interactive_workspace_root(agent)
18
20
  git_root = git_repository_root(root)
19
21
  if git_root.empty?
@@ -33,6 +35,8 @@ module Kward
33
35
  git_commit(git_root, message)
34
36
  end
35
37
  print_git_commit_result(result)
38
+ ensure
39
+ @git_hook_conversation = previous_git_hook_conversation
36
40
  end
37
41
 
38
42
  def git_repository_root(root)
@@ -48,7 +52,9 @@ module Kward
48
52
  output, status = Open3.capture2e("git", "status", "--short", "--untracked-files=normal", chdir: root.to_s)
49
53
  return ["Unable to read Git status: #{output.strip}"] unless status.success?
50
54
 
51
- output.lines.map(&:chomp)
55
+ lines = output.lines.map(&:chomp)
56
+ git_lifecycle_hook("git_status_after", root: root, payload: { status_count: lines.length })
57
+ lines
52
58
  rescue StandardError => e
53
59
  ["Unable to read Git status: #{e.message}"]
54
60
  end
@@ -70,7 +76,13 @@ module Kward
70
76
  entry = parse_git_status_line(status_line)
71
77
  return { path: "Git diff", content: "Unable to read Git status entry.\n" } if entry.nil?
72
78
 
79
+ before = git_lifecycle_hook("git_diff_before", root: root, payload: { path: entry[:path], untracked: entry[:untracked] })
80
+ if before.denied? || before.approval_required?
81
+ return { path: entry[:path], content: "Declined: #{before.decision.message || "git diff denied"}\n" }
82
+ end
83
+
73
84
  output = entry[:untracked] ? git_untracked_file_diff(root, entry[:path]) : git_tracked_file_diff(root, entry[:path])
85
+ git_lifecycle_hook("git_diff_after", root: root, payload: { path: entry[:path], untracked: entry[:untracked], bytes: output.bytesize })
74
86
  { path: entry[:path], content: output.empty? ? "No diff for #{entry[:path]}\n" : output }
75
87
  end
76
88
 
@@ -96,8 +108,13 @@ module Kward
96
108
  entry = parse_git_status_line(status_line)
97
109
  return if entry.nil?
98
110
 
111
+ action = entry[:staged] ? "unstage" : "stage"
112
+ before = git_lifecycle_hook("git_stage_before", root: root, payload: { path: entry[:path], action: action })
113
+ return if before.denied? || before.approval_required?
114
+
99
115
  command = entry[:staged] ? ["restore", "--staged", "--", entry[:path]] : ["add", "--", entry[:path]]
100
- Open3.capture2e("git", *command, chdir: root.to_s)
116
+ output, status = Open3.capture2e("git", *command, chdir: root.to_s)
117
+ git_lifecycle_hook("git_stage_after", root: root, payload: { path: entry[:path], action: action, success: status.success?, output: output })
101
118
  rescue StandardError
102
119
  nil
103
120
  end
@@ -115,6 +132,9 @@ module Kward
115
132
  end
116
133
 
117
134
  def git_commit(root, message)
135
+ before = git_lifecycle_hook("git_commit_before", root: root, payload: { message: message.to_s })
136
+ return { success: false, output: "Declined: #{before.decision.message || "git commit denied"}" } if before.denied? || before.approval_required?
137
+
118
138
  return git_commit_staged(root, message) if git_staged_changes?(root)
119
139
 
120
140
  add_output, add_status = Open3.capture2e("git", "add", "--all", chdir: root.to_s)
@@ -134,11 +154,18 @@ module Kward
134
154
 
135
155
  def git_commit_staged(root, message)
136
156
  commit_output, commit_status = Open3.capture2e("git", "commit", "-m", message.to_s, chdir: root.to_s)
137
- { success: commit_status.success?, output: commit_output }
157
+ result = { success: commit_status.success?, output: commit_output }
158
+ git_lifecycle_hook("git_commit_after", root: root, payload: { message: message.to_s, success: result[:success], output: result[:output] })
159
+ result
138
160
  rescue StandardError => e
139
161
  { success: false, output: e.message }
140
162
  end
141
163
 
164
+ def git_lifecycle_hook(name, root:, payload: {})
165
+ conversation = @git_hook_conversation || new_conversation(workspace_root: root.to_s)
166
+ run_lifecycle_hook(name, conversation: conversation, payload: { root: root.to_s }.merge(payload))
167
+ end
168
+
142
169
  def print_git_commit_result(result)
143
170
  output = result[:output].to_s.strip
144
171
  output = result[:success] ? "Commit created." : "Git commit failed." if output.empty?
@@ -0,0 +1,258 @@
1
+ require "json"
2
+ require "uri"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
7
+ class CLI
8
+ # Interactive lifecycle hook inspection commands.
9
+ module HookCommands
10
+ private
11
+
12
+ def handle_hooks_command(argument)
13
+ subcommand, rest = argument.to_s.strip.split(/\s+/, 2)
14
+ subcommand = "list" if subcommand.to_s.empty?
15
+
16
+ case subcommand
17
+ when "list"
18
+ print_hooks_list
19
+ when "events"
20
+ print_hooks_events
21
+ when "logs"
22
+ print_hooks_logs(rest)
23
+ when "doctor"
24
+ print_hooks_doctor
25
+ when "trust"
26
+ trust_workspace_hooks
27
+ when "untrust"
28
+ untrust_workspace_hooks
29
+ else
30
+ runtime_output("Usage: /hooks [list|events|logs|doctor|trust|untrust]")
31
+ end
32
+ end
33
+
34
+ def print_hooks_list
35
+ lines = ["Lifecycle hooks"]
36
+ entries = configured_hook_entries + workspace_hook_entries + plugin_hook_entries
37
+ if entries.empty?
38
+ lines << "No lifecycle hooks are configured."
39
+ else
40
+ entries.each do |entry|
41
+ details = [entry.fetch(:id), entry.fetch(:event)]
42
+ details << "source=#{entry.fetch(:source)}"
43
+ details << "order=#{entry.fetch(:order)}"
44
+ details << "failure_policy=#{entry[:failure_policy]}" if entry[:failure_policy]
45
+ details << "disabled" if entry[:disabled]
46
+ lines << "- #{details.join(' ')}"
47
+ end
48
+ end
49
+ runtime_output(lines.join("\n"))
50
+ end
51
+
52
+ def print_hooks_events
53
+ lines = ["Lifecycle hook events"]
54
+ Hooks::Catalog.event_names.each do |event_name|
55
+ definition = Hooks::Catalog.definition(event_name)
56
+ fields = Array(definition&.modifiable_fields)
57
+ suffix = fields.empty? ? "" : " modifies=#{fields.join(',')}"
58
+ lines << "- #{event_name} failure_policy=#{Hooks::Catalog.failure_policy(event_name)}#{suffix}"
59
+ end
60
+ runtime_output(lines.join("\n"))
61
+ end
62
+
63
+ def print_hooks_logs(argument)
64
+ count = argument.to_s.strip.empty? ? 20 : argument.to_i
65
+ count = 20 unless count.positive?
66
+ path = hooks_log_path
67
+ unless File.file?(path)
68
+ runtime_output("No lifecycle hook audit log found at #{path}.")
69
+ return
70
+ end
71
+
72
+ records = File.readlines(path, chomp: true).last(count).filter_map do |line|
73
+ JSON.parse(line)
74
+ rescue JSON::ParserError
75
+ nil
76
+ end
77
+ if records.empty?
78
+ runtime_output("No readable lifecycle hook audit records found at #{path}.")
79
+ return
80
+ end
81
+
82
+ lines = ["Lifecycle hook audit log: #{path}"]
83
+ records.each do |record|
84
+ lines << format_hook_log_record(record)
85
+ end
86
+ runtime_output(lines.join("\n"))
87
+ end
88
+
89
+ def print_hooks_doctor
90
+ lines = ["Lifecycle hook diagnostics"]
91
+ begin
92
+ diagnostics = hook_config_diagnostics(ConfigFiles.read_config, "config") + hook_config_diagnostics(ConfigFiles.read_trusted_workspace_hooks_config(current_workspace_root), "workspace")
93
+ manager = diagnostics.empty? ? Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(current_workspace_root)).manager : nil
94
+ lines << "Config and trusted workspace hooks: #{manager ? manager.handlers.length : "not loaded due diagnostics"}"
95
+ lines << "Plugin hooks: #{plugin_registry.hook_handlers.length}"
96
+ lines << "Audit log: #{hooks_log_path}"
97
+ lines << "Workspace hook config: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}"
98
+ lines << "Workspace hooks trusted: #{ConfigFiles.workspace_hooks_trusted?(current_workspace_root)}"
99
+ lines << "Known events: #{Hooks::Catalog.event_names.length}"
100
+ if diagnostics.empty?
101
+ lines << "OK"
102
+ else
103
+ lines.concat(diagnostics.map { |diagnostic| "Warning: #{diagnostic}" })
104
+ end
105
+ rescue StandardError => e
106
+ lines << "Error: #{e.message}"
107
+ end
108
+ runtime_output(lines.join("\n"))
109
+ end
110
+
111
+ def configured_hook_entries
112
+ hook_entries_from_config(ConfigFiles.read_config, "config")
113
+ end
114
+
115
+ def workspace_hook_entries
116
+ hook_entries_from_config(ConfigFiles.read_trusted_workspace_hooks_config(current_workspace_root), "workspace")
117
+ end
118
+
119
+ def plugin_hook_entries
120
+ plugin_registry.hook_handlers.map do |hook|
121
+ {
122
+ id: hook.id,
123
+ event: hook.event,
124
+ source: hook.path || "plugin",
125
+ order: hook.order,
126
+ failure_policy: hook.failure_policy,
127
+ disabled: false
128
+ }
129
+ end
130
+ end
131
+
132
+ def hook_entries_from_config(config, source)
133
+ hooks = config["hooks"] || config[:hooks]
134
+ return [] unless hooks.is_a?(Hash)
135
+
136
+ hooks.flat_map do |event, entries|
137
+ Array(entries).each_with_index.map do |entry, index|
138
+ entry = entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : { "command" => entry.to_s }
139
+ {
140
+ id: entry["id"] || "#{source}:#{event}:#{index + 1}",
141
+ event: event.to_s,
142
+ source: source,
143
+ order: entry.fetch("order", 100),
144
+ failure_policy: entry["failure_policy"],
145
+ disabled: truthy_hook_value?(entry["disabled"])
146
+ }
147
+ end
148
+ end
149
+ end
150
+
151
+ def hook_config_diagnostics(config, source)
152
+ hooks = config["hooks"] || config[:hooks]
153
+ return [] unless hooks.is_a?(Hash)
154
+
155
+ hooks.flat_map do |event, entries|
156
+ Array(entries).each_with_index.flat_map do |entry, index|
157
+ validate_hook_entry(event.to_s, normalize_hook_entry(entry), source, index + 1)
158
+ end
159
+ end
160
+ end
161
+
162
+ def normalize_hook_entry(entry)
163
+ entry.is_a?(Hash) ? entry.transform_keys(&:to_s) : { "type" => "command", "command" => entry.to_s }
164
+ end
165
+
166
+ def validate_hook_entry(event, entry, source, number)
167
+ label = entry["id"] || "#{source}:#{event}:#{number}"
168
+ diagnostics = []
169
+ diagnostics << "#{label}: unknown event #{event}" unless Hooks::Catalog.known?(event)
170
+ diagnostics.concat(validate_hook_failure_policy(label, entry))
171
+ diagnostics.concat(validate_hook_timeout(label, entry))
172
+ diagnostics.concat(validate_hook_type(label, entry))
173
+ diagnostics
174
+ end
175
+
176
+ def validate_hook_failure_policy(label, entry)
177
+ policy = entry["failure_policy"]
178
+ return [] if policy.to_s.empty? || Hooks::Catalog::VALID_FAILURE_POLICIES.include?(policy.to_s)
179
+
180
+ ["#{label}: unknown failure_policy #{policy}"]
181
+ end
182
+
183
+ def validate_hook_timeout(label, entry)
184
+ value = entry["timeout_seconds"]
185
+ return [] if value.nil? || value.to_f.positive?
186
+
187
+ ["#{label}: timeout_seconds must be positive"]
188
+ end
189
+
190
+ def validate_hook_type(label, entry)
191
+ type = (entry["type"] || "command").to_s
192
+ case type
193
+ when "command"
194
+ validate_command_hook(label, entry)
195
+ when "http"
196
+ validate_http_hook(label, entry)
197
+ else
198
+ ["#{label}: unsupported hook type #{type}"]
199
+ end
200
+ end
201
+
202
+ def validate_command_hook(label, entry)
203
+ command = entry["command"].to_s.strip
204
+ return ["#{label}: command is required"] if command.empty?
205
+
206
+ executable = command.split(/\s+/, 2).first
207
+ return [] if executable.include?(File::SEPARATOR) ? File.executable?(File.expand_path(executable)) : system_command_available?(executable)
208
+
209
+ ["#{label}: command executable not found: #{executable}"]
210
+ end
211
+
212
+ def validate_http_hook(label, entry)
213
+ uri = URI.parse(entry["url"].to_s)
214
+ return [] if %w[http https].include?(uri.scheme) && uri.host
215
+
216
+ ["#{label}: url must be http or https"]
217
+ rescue URI::InvalidURIError
218
+ ["#{label}: url must be http or https"]
219
+ end
220
+
221
+ def system_command_available?(name)
222
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
223
+ File.executable?(File.join(dir, name))
224
+ end
225
+ end
226
+
227
+ def trust_workspace_hooks
228
+ ConfigFiles.trust_workspace_hooks!(current_workspace_root)
229
+ runtime_output("Trusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
230
+ rescue StandardError => e
231
+ runtime_output("Workspace hook trust error: #{e.message}")
232
+ end
233
+
234
+ def untrust_workspace_hooks
235
+ ConfigFiles.untrust_workspace_hooks!(current_workspace_root)
236
+ runtime_output("Untrusted workspace hooks: #{ConfigFiles.workspace_hooks_path(current_workspace_root)}")
237
+ rescue StandardError => e
238
+ runtime_output("Workspace hook trust error: #{e.message}")
239
+ end
240
+
241
+ def hooks_log_path
242
+ File.join(ConfigFiles.config_dir, "logs", "hooks.jsonl")
243
+ end
244
+
245
+ def format_hook_log_record(record)
246
+ parts = [record["timestamp"], record["kind"], record["event"]].compact
247
+ parts << "hook=#{record['hook_id']}" if record["hook_id"]
248
+ parts << "decision=#{record['decision']}" if record["decision"]
249
+ parts << record["message"] if record["message"]
250
+ "- #{parts.join(' ')}"
251
+ end
252
+
253
+ def truthy_hook_value?(value)
254
+ value == true || value.to_s == "true"
255
+ end
256
+ end
257
+ end
258
+ end
@@ -116,6 +116,9 @@ module Kward
116
116
  when Events::ReasoningDelta
117
117
  stream_state[:streamed] = true
118
118
  append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
119
+ when Events::ReasoningBoundary
120
+ stream_state[:streamed] = true
121
+ finish_interactive_markdown_deltas(markdown_chunks, stream_state)
119
122
  when Events::AssistantDelta
120
123
  stream_state[:streamed] = true
121
124
  append_markdown_delta(markdown_chunks, "Assistant", event.delta)
@@ -102,11 +102,7 @@ module Kward
102
102
  end
103
103
 
104
104
  def prepare_memory_context(conversation, input)
105
- manager = Memory::Manager.new
106
- retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
107
- conversation.last_memory_retrieval = retrieval
108
- conversation.memory_context = manager.memory_block(retrieval)
109
- conversation.refresh_system_message!
105
+ Memory::TurnContext.apply(conversation: conversation, input: input)
110
106
  rescue StandardError => e
111
107
  warn "Memory retrieval failed: #{e.message}"
112
108
  nil
@@ -37,6 +37,39 @@ module Kward
37
37
  runtime_output("Plugins reloaded.")
38
38
  end
39
39
 
40
+ def lifecycle_hook_manager(conversation)
41
+ manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(conversation.workspace_root)).manager
42
+ plugin_registry.hook_handlers.each do |hook|
43
+ manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
44
+ hook.handler.call(event, context)
45
+ end
46
+ end
47
+ manager
48
+ end
49
+
50
+ def lifecycle_hook_context(conversation)
51
+ plugin_context(conversation, "")
52
+ end
53
+
54
+ def run_lifecycle_hook(name, conversation:, payload: {}, session: @active_session)
55
+ lifecycle_hook_manager(conversation).run(Hooks::Event.new(
56
+ name: name,
57
+ session: session_payload(session),
58
+ workspace: { root: conversation.respond_to?(:workspace_root) ? conversation.workspace_root : current_workspace_root },
59
+ payload: payload
60
+ ), context: lifecycle_hook_context(conversation))
61
+ end
62
+
63
+ def session_payload(session)
64
+ return {} unless session
65
+
66
+ {
67
+ id: session.respond_to?(:id) ? session.id : nil,
68
+ name: session.respond_to?(:name) ? session.name : nil,
69
+ path: session.respond_to?(:path) ? session.path : nil
70
+ }.compact
71
+ end
72
+
40
73
  def reserved_slash_command_names
41
74
  builtin_slash_command_names + prompt_templates.map(&:command)
42
75
  end
@@ -76,9 +109,16 @@ module Kward
76
109
  argument_hint: template.argument_hint
77
110
  }
78
111
  end
112
+ skill_entries = ConfigFiles.skills.map do |skill|
113
+ {
114
+ name: "skill:#{skill.name}",
115
+ description: skill.description,
116
+ argument_hint: ""
117
+ }
118
+ end
79
119
  plugin_entries = plugin_commands.map(&:entry)
80
120
  interactive_entries = interactive_commands.map(&:entry)
81
- builtin_slash_commands + prompt_entries + plugin_entries + interactive_entries
121
+ builtin_slash_commands + prompt_entries + skill_entries + plugin_entries + interactive_entries
82
122
  end
83
123
 
84
124
  def prompt_template_for(command)
@@ -37,7 +37,9 @@ module Kward
37
37
  editor_bar_cursor: ConfigFiles.editor_bar_cursor?,
38
38
  editor_bar_cursor_source: -> { ConfigFiles.editor_bar_cursor? },
39
39
  editor_line_numbers: ConfigFiles.editor_line_numbers,
40
- editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers }
40
+ editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers },
41
+ diff_view: ConfigFiles.diff_view,
42
+ diff_view_source: -> { ConfigFiles.diff_view }
41
43
  )
42
44
  if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
43
45
  @prompt.start(render: false)
@@ -68,12 +70,13 @@ module Kward
68
70
  def print_visual_banner
69
71
  return unless @prompt.respond_to?(:print_visual_banner)
70
72
 
71
- @prompt.print_visual_banner(startup_info_screen)
73
+ @prompt.print_visual_banner(startup_info_screen(refresh_update_check: prompt_interface?))
72
74
  end
73
75
 
74
- def startup_info_screen
76
+ def startup_info_screen(refresh_update_check: false)
75
77
  [
76
- startup_status_line,
78
+ startup_status_line(refresh_update_check: refresh_update_check),
79
+ *startup_update_notice_lines,
77
80
  "",
78
81
  startup_info_line("Workspace", startup_workspace_label),
79
82
  startup_info_line("Branch", startup_branch_value),
@@ -115,8 +118,26 @@ module Kward
115
118
  filenames.empty? ? "none" : filenames.join(", ")
116
119
  end
117
120
 
118
- def startup_status_line
119
- "#{ANSI.colorize("●", :green, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
121
+ def startup_status_line(refresh_update_check: false)
122
+ color = startup_update_notice(refresh: refresh_update_check) ? :yellow : :green
123
+ "#{ANSI.colorize("●", color, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
124
+ end
125
+
126
+ def startup_update_notice_lines
127
+ notice = startup_update_notice
128
+ return [] unless notice
129
+
130
+ [
131
+ " New version available: #{notice.latest_version}",
132
+ " Run: gem update kward"
133
+ ]
134
+ end
135
+
136
+ def startup_update_notice(refresh: false)
137
+ return @startup_update_notice if defined?(@startup_update_notice) && !refresh
138
+
139
+ @startup_update_check ||= UpdateCheck.new(current_version: Kward::VERSION)
140
+ @startup_update_notice = @startup_update_check.notice(refresh: refresh)
120
141
  end
121
142
 
122
143
  def startup_info_line(label, value)
@@ -218,7 +239,7 @@ module Kward
218
239
  def composer_context_usage(provider, model)
219
240
  context_window = composer_context_window(provider, model)
220
241
  context_parts = if @client.respond_to?(:current_context_parts)
221
- @client.current_context_parts(current_footer_conversation.context_messages, footer_tool_schemas)
242
+ @client.current_context_parts(current_footer_conversation.context_messages, footer_tool_schemas, provider: provider, model: model)
222
243
  else
223
244
  { provider: provider, model: model, messages: current_footer_conversation.context_messages, tools: footer_tool_schemas }
224
245
  end
@@ -38,8 +38,9 @@ module Kward
38
38
  end
39
39
 
40
40
  def render_reasoning(message)
41
- reasoning = CLITranscriptFormatter.reasoning(message)
42
- render_transcript_block("Reasoning", reasoning) unless reasoning.empty?
41
+ CLITranscriptFormatter.reasoning_blocks(message).each do |reasoning|
42
+ render_transcript_block("Reasoning", reasoning)
43
+ end
43
44
  end
44
45
 
45
46
  def render_assistant_message(message)
@@ -66,7 +67,7 @@ module Kward
66
67
  def render_transcript_block(label, content)
67
68
  return if content.to_s.empty?
68
69
 
69
- rendered = render_markdown_transcript(content)
70
+ rendered = render_markdown_transcript(content).sub(/\n+\z/, "")
70
71
  if prompt_interface?
71
72
  print_block_delta(label, rendered)
72
73
  finish_stream_block
@@ -84,6 +85,9 @@ module Kward
84
85
  when Events::ReasoningDelta
85
86
  append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
86
87
  :streamed
88
+ when Events::ReasoningBoundary
89
+ flush_markdown_deltas(markdown_chunks)
90
+ :streamed
87
91
  when Events::AssistantDelta
88
92
  append_markdown_delta(markdown_chunks, "Assistant", event.delta)
89
93
  :assistant_streamed
@@ -183,7 +187,7 @@ module Kward
183
187
  end
184
188
 
185
189
  def tool_call_id(tool_call)
186
- tool_call["id"] || tool_call[:id]
190
+ ToolCall.id(tool_call)
187
191
  end
188
192
 
189
193
  # Writes the user transcript output for the terminal CLI flow.
@@ -52,19 +52,25 @@ module Kward
52
52
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
53
53
  workspace = configured_workspace(root: conversation.workspace_root)
54
54
  writer_id = worker_writer_id(role)
55
+ hook_manager = lifecycle_hook_manager(conversation)
56
+ hook_context = lifecycle_hook_context(conversation)
55
57
  tool_registry = ToolRegistry.new(
56
58
  workspace: workspace,
57
59
  prompt: @prompt,
58
60
  allowed_tool_names: Workers::ToolPolicy.allowed_tool_names(role),
59
61
  write_lock: @worker_write_lock,
60
- writer_id: writer_id
62
+ writer_id: writer_id,
63
+ hook_manager: hook_manager,
64
+ hook_context: hook_context
61
65
  )
62
66
  @footer_conversation = conversation
63
67
  @footer_tool_registry = tool_registry
64
68
  Agent.new(
65
69
  client: @client,
66
70
  tool_registry: tool_registry,
67
- conversation: conversation
71
+ conversation: conversation,
72
+ hook_manager: hook_manager,
73
+ hook_context: hook_context
68
74
  )
69
75
  end
70
76
 
@@ -356,7 +362,7 @@ module Kward
356
362
  queued_inputs = []
357
363
  result = nil
358
364
  error = nil
359
- @prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
365
+ begin_local_busy_command(activity)
360
366
 
361
367
  worker = Thread.new do
362
368
  result = yield
@@ -365,7 +371,8 @@ module Kward
365
371
  end
366
372
 
367
373
  while worker.alive?
368
- collect_queued_input(queued_inputs)
374
+ poll_result = collect_queued_input(queued_inputs)
375
+ handle_busy_local_tab_action(poll_result, activity: activity) if busy_local_tab_action?(poll_result)
369
376
  sleep 0.02
370
377
  end
371
378
  worker.join
@@ -374,7 +381,7 @@ module Kward
374
381
 
375
382
  [result, queued_inputs]
376
383
  ensure
377
- @prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input)
384
+ finish_local_busy_command(activity)
378
385
  end
379
386
 
380
387
  def run_busy_local_command_and_requeue(activity: "loading")
@@ -385,6 +392,38 @@ module Kward
385
392
  result
386
393
  end
387
394
 
395
+ def begin_local_busy_command(activity)
396
+ active_tab.local_busy_activity = activity if active_tab
397
+ @prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
398
+ update_prompt_tabs if respond_to?(:update_prompt_tabs, true)
399
+ end
400
+
401
+ def finish_local_busy_command(activity)
402
+ tab = local_busy_tab(activity)
403
+ tab.local_busy_activity = nil if tab
404
+ update_prompt_tabs if respond_to?(:update_prompt_tabs, true)
405
+ @prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input) && (!active_tab || active_tab == tab)
406
+ end
407
+
408
+ def local_busy_tab(activity)
409
+ Array(@tabs).find { |tab| tab.local_busy_activity.to_s == activity.to_s }
410
+ end
411
+
412
+ def busy_local_tab_action?(poll_result)
413
+ poll_result.is_a?(Hash) && poll_result[:tab_action]
414
+ end
415
+
416
+ def handle_busy_local_tab_action(action, activity:)
417
+ return unless respond_to?(:handle_tab_action, true)
418
+
419
+ @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
420
+ handle_tab_action(action, @session_store)
421
+ tab = active_tab
422
+ return unless tab&.local_busy?
423
+
424
+ @prompt.begin_busy_input("You>", activity: tab.local_busy_activity) if @prompt.respond_to?(:begin_busy_input)
425
+ end
426
+
388
427
  def current_workspace_root
389
428
  return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
390
429
  return @working_directory if @working_directory