kward 0.75.0 → 0.77.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +48 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +12 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/composer.md +219 -0
  9. data/doc/configuration.md +87 -27
  10. data/doc/editor.md +18 -2
  11. data/doc/files.md +1 -1
  12. data/doc/getting-started.md +4 -0
  13. data/doc/git.md +2 -2
  14. data/doc/lifecycle-hooks.md +2 -14
  15. data/doc/pan.md +167 -0
  16. data/doc/plugins.md +1 -0
  17. data/doc/rpc.md +3 -22
  18. data/doc/security.md +180 -0
  19. data/doc/usage.md +15 -32
  20. data/lib/kward/agent.rb +5 -0
  21. data/lib/kward/ansi.rb +2 -2
  22. data/lib/kward/cancellation.rb +2 -2
  23. data/lib/kward/cli/commands.rb +1 -3
  24. data/lib/kward/cli/interactive_turn.rb +3 -17
  25. data/lib/kward/cli/plugins.rb +2 -7
  26. data/lib/kward/cli/prompt_interface.rb +10 -17
  27. data/lib/kward/cli/rendering.rb +7 -3
  28. data/lib/kward/cli/runtime_helpers.rb +0 -38
  29. data/lib/kward/cli/sessions.rb +14 -5
  30. data/lib/kward/cli/settings.rb +22 -0
  31. data/lib/kward/cli/slash_commands.rb +1 -561
  32. data/lib/kward/cli/tabs.rb +14 -2
  33. data/lib/kward/cli.rb +1 -17
  34. data/lib/kward/cli_transcript_formatter.rb +21 -11
  35. data/lib/kward/compactor.rb +3 -3
  36. data/lib/kward/config_files.rb +21 -0
  37. data/lib/kward/ekwsh.rb +0 -4
  38. data/lib/kward/events.rb +1 -0
  39. data/lib/kward/hooks/catalog.rb +0 -5
  40. data/lib/kward/image_attachments.rb +1 -1
  41. data/lib/kward/model/client.rb +12 -9
  42. data/lib/kward/model/model_info.rb +14 -5
  43. data/lib/kward/model/stream_parser.rb +12 -12
  44. data/lib/kward/pan/index.html.erb +460 -64
  45. data/lib/kward/pan/kward_logo.png +0 -0
  46. data/lib/kward/pan/server.rb +250 -23
  47. data/lib/kward/plugin_registry.rb +20 -0
  48. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  49. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  50. data/lib/kward/prompt_interface/layout.rb +0 -4
  51. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  52. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  53. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  54. data/lib/kward/prompt_interface/transcript_renderer.rb +1 -13
  55. data/lib/kward/prompt_interface.rb +18 -1
  56. data/lib/kward/prompts/commands.rb +1 -3
  57. data/lib/kward/prompts.rb +20 -0
  58. data/lib/kward/rpc/server.rb +14 -42
  59. data/lib/kward/rpc/session_manager.rb +77 -25
  60. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  61. data/lib/kward/rpc/tool_metadata.rb +23 -0
  62. data/lib/kward/rpc/transcript_normalizer.rb +14 -10
  63. data/lib/kward/session_store.rb +55 -16
  64. data/lib/kward/skills/registry.rb +21 -1
  65. data/lib/kward/tools/base.rb +14 -0
  66. data/lib/kward/tools/registry.rb +11 -16
  67. data/lib/kward/tools/search/code.rb +1 -1
  68. data/lib/kward/tools/search/web.rb +1 -1
  69. data/lib/kward/tools/tool_call.rb +0 -4
  70. data/lib/kward/transcript_export.rb +1 -1
  71. data/lib/kward/update_check.rb +12 -7
  72. data/lib/kward/version.rb +1 -1
  73. data/templates/default/kward_navigation.rb +5 -1
  74. data/templates/default/layout/html/layout.erb +0 -2
  75. metadata +5 -12
  76. data/lib/kward/workers/git_guard.rb +0 -93
  77. data/lib/kward/workers/job.rb +0 -99
  78. data/lib/kward/workers/live_view.rb +0 -49
  79. data/lib/kward/workers/manager.rb +0 -328
  80. data/lib/kward/workers/queue_runner.rb +0 -166
  81. data/lib/kward/workers/queue_store.rb +0 -112
  82. data/lib/kward/workers/store.rb +0 -72
  83. data/lib/kward/workers/tool_policy.rb +0 -23
  84. data/lib/kward/workers/worker.rb +0 -82
  85. data/lib/kward/workers/write_lock.rb +0 -38
  86. data/lib/kward/workers.rb +0 -10
data/lib/kward/agent.rb CHANGED
@@ -215,6 +215,10 @@ module Kward
215
215
  on_reasoning_delta&.call(delta)
216
216
  yield Events::ReasoningDelta.new(delta: delta) if block_given?
217
217
  end
218
+ reasoning_boundary = lambda do
219
+ cancellation&.raise_if_cancelled!
220
+ yield Events::ReasoningBoundary.new if block_given?
221
+ end
218
222
  assistant_delta = lambda do |delta|
219
223
  cancellation&.raise_if_cancelled!
220
224
  yield Events::AssistantDelta.new(delta: delta) if block_given?
@@ -244,6 +248,7 @@ module Kward
244
248
  {
245
249
  tools: request[:tools] || request["tools"],
246
250
  on_reasoning_delta: reasoning_delta,
251
+ on_reasoning_boundary: reasoning_boundary,
247
252
  on_assistant_delta: assistant_delta,
248
253
  on_retry: retry_callback,
249
254
  cancellation: cancellation,
data/lib/kward/ansi.rb CHANGED
@@ -262,8 +262,8 @@ module Kward
262
262
  end
263
263
 
264
264
  def inline_links(text, enabled: enabled?)
265
- text.split(/(\[[^\]\n]+\]\([^)\s\n]+\))/).map do |part|
266
- if (match = part.match(/\A\[([^\]\n]+)\]\(([^)\s\n]+)\)\z/))
265
+ text.split(/(\[[^\n\]]+\]\([^)\s]+\))/).map do |part|
266
+ if (match = part.match(/\A\[([^\n\]]+)\]\(([^)\s]+)\)\z/))
267
267
  "#{colorize(match[1], :cyan, enabled: enabled)} (#{colorize(match[2], :dim, enabled: enabled)})"
268
268
  else
269
269
  inline_emphasis(part, enabled: enabled)
@@ -2,9 +2,9 @@ require "thread"
2
2
 
3
3
  # Namespace for the Kward CLI agent runtime.
4
4
  module Kward
5
- # Cooperative cancellation token shared by model calls, tools, and workers.
5
+ # Cooperative cancellation token shared by model calls, tools, and background operations.
6
6
  class Cancellation
7
- # Cooperative cancellation token shared by model calls, tools, and workers.
7
+ # Cooperative cancellation token shared by model calls, tools, and background operations.
8
8
  class CancelledError < StandardError; end
9
9
 
10
10
  def initialize
@@ -156,7 +156,7 @@ module Kward
156
156
  },
157
157
  "pan" => {
158
158
  usage: "kward pan",
159
- description: "Start Pan mode, a minimal LAN web UI with a prompt textarea and transcript.",
159
+ description: "Start Pan mode, a mobile-friendly LAN web UI with persistent sessions.",
160
160
  examples: ["kward pan", "kward --working-directory ~/code/project pan"]
161
161
  },
162
162
  "rpc" => {
@@ -220,8 +220,6 @@ module Kward
220
220
  @prompt_delimited = true
221
221
  remaining.concat(arguments[(index + 1)..] || [])
222
222
  break
223
- when "--experimental-workers"
224
- @experimental_workers = true
225
223
  when "--skip-config"
226
224
  @skip_config = true
227
225
  when "--filter"
@@ -7,7 +7,6 @@ module Kward
7
7
  private
8
8
 
9
9
  def run_interactive_turn(agent, input, display_input: nil)
10
- stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
11
10
  prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
12
11
  print_user_transcript(input, display_input: display_input) if prompt_interface?
13
12
  return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
@@ -116,6 +115,9 @@ module Kward
116
115
  when Events::ReasoningDelta
117
116
  stream_state[:streamed] = true
118
117
  append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
118
+ when Events::ReasoningBoundary
119
+ stream_state[:streamed] = true
120
+ finish_interactive_markdown_deltas(markdown_chunks, stream_state)
119
121
  when Events::AssistantDelta
120
122
  stream_state[:streamed] = true
121
123
  append_markdown_delta(markdown_chunks, "Assistant", event.delta)
@@ -232,22 +234,6 @@ module Kward
232
234
  ["/exit", "/quit", "/new"].include?(input.to_s.strip)
233
235
  end
234
236
 
235
- def handle_busy_worker_input(input, agent, queued_inputs)
236
- return false unless agent
237
-
238
- command = input.to_s.strip
239
- return false unless command == "/workers" || command.start_with?("/workers ")
240
-
241
- _handled, replacement_agent = handle_local_slash_command(command, agent, @session_store)
242
- @busy_replacement_agent = replacement_agent if replacement_agent?(replacement_agent)
243
- restore_busy_input_prompt
244
- true
245
- rescue StandardError => e
246
- runtime_output("Error: #{e.message}")
247
- restore_busy_input_prompt
248
- true
249
- end
250
-
251
237
  def replacement_agent?(object)
252
238
  object.respond_to?(:conversation) && object.respond_to?(:ask)
253
239
  end
@@ -32,6 +32,7 @@ module Kward
32
32
 
33
33
  def reload_plugins(conversation)
34
34
  @plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
35
+ @prompt.update_slash_commands(slash_command_entries) if @prompt.respond_to?(:update_slash_commands)
35
36
  conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
36
37
  conversation.refresh_system_message! if conversation.respond_to?(:refresh_system_message!)
37
38
  runtime_output("Plugins reloaded.")
@@ -126,19 +127,13 @@ module Kward
126
127
  end
127
128
 
128
129
  def builtin_slash_commands
129
- return BUILTIN_SLASH_COMMANDS if experimental_workers_enabled?
130
-
131
- BUILTIN_SLASH_COMMANDS.reject { |command| %w[workers queue].include?(command[:name]) }
130
+ BUILTIN_SLASH_COMMANDS
132
131
  end
133
132
 
134
133
  def builtin_slash_command_names
135
134
  builtin_slash_commands.map { |command| command[:name] }
136
135
  end
137
136
 
138
- def experimental_workers_enabled?
139
- @experimental_workers == true
140
- end
141
-
142
137
  def expand_prompt_template(input)
143
138
  PromptCommands.expand(input, templates: prompt_templates, reserved_commands: builtin_slash_command_names)
144
139
  end
@@ -18,6 +18,7 @@ module Kward
18
18
  @prompt = prompt_interface.new(
19
19
  slash_commands: slash_command_entries,
20
20
  overlay_settings: ConfigFiles.overlay_settings,
21
+ project_browser_icon_theme: ConfigFiles.project_browser_icon_theme,
21
22
  footer: prompt_footer_renderer,
22
23
  composer_status: method(:composer_status_text),
23
24
  busy_help: ConfigFiles.composer_busy_help?,
@@ -70,13 +71,12 @@ module Kward
70
71
  def print_visual_banner
71
72
  return unless @prompt.respond_to?(:print_visual_banner)
72
73
 
73
- @prompt.print_visual_banner(startup_info_screen)
74
- refresh_startup_update_check if prompt_interface?
74
+ @prompt.print_visual_banner(startup_info_screen(refresh_update_check: prompt_interface?))
75
75
  end
76
76
 
77
- def startup_info_screen
77
+ def startup_info_screen(refresh_update_check: false)
78
78
  [
79
- startup_status_line,
79
+ startup_status_line(refresh_update_check: refresh_update_check),
80
80
  *startup_update_notice_lines,
81
81
  "",
82
82
  startup_info_line("Workspace", startup_workspace_label),
@@ -119,8 +119,8 @@ module Kward
119
119
  filenames.empty? ? "none" : filenames.join(", ")
120
120
  end
121
121
 
122
- def startup_status_line
123
- color = startup_update_notice ? :yellow : :green
122
+ def startup_status_line(refresh_update_check: false)
123
+ color = startup_update_notice(refresh: refresh_update_check) ? :yellow : :green
124
124
  "#{ANSI.colorize("●", color, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
125
125
  end
126
126
 
@@ -134,18 +134,11 @@ module Kward
134
134
  ]
135
135
  end
136
136
 
137
- def startup_update_notice
138
- return @startup_update_notice if defined?(@startup_update_notice)
137
+ def startup_update_notice(refresh: false)
138
+ return @startup_update_notice if defined?(@startup_update_notice) && !refresh
139
139
 
140
- @startup_update_check = UpdateCheck.new(current_version: Kward::VERSION)
141
- @startup_update_notice = @startup_update_check.notice
142
- end
143
-
144
- def refresh_startup_update_check
145
- checker = @startup_update_check || UpdateCheck.new(current_version: Kward::VERSION)
146
- return unless checker.enabled?
147
-
148
- Thread.new { checker.refresh_if_stale }
140
+ @startup_update_check ||= UpdateCheck.new(current_version: Kward::VERSION)
141
+ @startup_update_notice = @startup_update_check.notice(refresh: refresh)
149
142
  end
150
143
 
151
144
  def startup_info_line(label, value)
@@ -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
@@ -43,23 +43,13 @@ module Kward
43
43
  end
44
44
 
45
45
  def build_interactive_agent(conversation)
46
- @active_worker_role = "implementation"
47
- set_visible_worker("implementation", status: "active")
48
- build_worker_agent(conversation, role: "implementation")
49
- end
50
-
51
- def build_worker_agent(conversation, role: "implementation")
52
46
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
53
47
  workspace = configured_workspace(root: conversation.workspace_root)
54
- writer_id = worker_writer_id(role)
55
48
  hook_manager = lifecycle_hook_manager(conversation)
56
49
  hook_context = lifecycle_hook_context(conversation)
57
50
  tool_registry = ToolRegistry.new(
58
51
  workspace: workspace,
59
52
  prompt: @prompt,
60
- allowed_tool_names: Workers::ToolPolicy.allowed_tool_names(role),
61
- write_lock: @worker_write_lock,
62
- writer_id: writer_id,
63
53
  hook_manager: hook_manager,
64
54
  hook_context: hook_context
65
55
  )
@@ -74,34 +64,6 @@ module Kward
74
64
  )
75
65
  end
76
66
 
77
- def set_visible_worker(id, status: nil, worker: nil)
78
- @visible_worker_id = id.to_s
79
- @visible_worker_status = status
80
- @visible_worker = worker
81
- end
82
-
83
- def worker_writer_id(role)
84
- return nil unless Workers::ToolPolicy.write_capable?(role)
85
-
86
- @worker_write_lock ||= Workers::WriteLock.new
87
- owner_id = role.to_s.empty? ? "implementation" : role.to_s
88
- return owner_id if @worker_write_lock.acquire(owner_id)
89
-
90
- nil
91
- end
92
-
93
- def refresh_implementation_writer(agent)
94
- return agent unless @active_worker_role == "implementation"
95
- return agent unless agent&.respond_to?(:tool_registry)
96
- return agent if agent.tool_registry.writer_id && @worker_write_lock&.owned_by?(agent.tool_registry.writer_id)
97
-
98
- build_interactive_agent(agent.conversation)
99
- end
100
-
101
- def release_implementation_writer
102
- @worker_write_lock&.release("implementation")
103
- end
104
-
105
67
  def handle_interactive_shell_command(input, agent)
106
68
  command = input.to_s.sub(/\A!\s*/, "")
107
69
  if command.strip.empty?
@@ -80,6 +80,15 @@ module Kward
80
80
  @cleanup_sessions.clear
81
81
  end
82
82
 
83
+ def live_session_paths
84
+ (Array(@tabs).filter_map { |tab| tab.session&.path } + [@active_session&.path]).compact.uniq
85
+ end
86
+
87
+ def recent_sessions(session_store, tree: false, limit: nil)
88
+ method = tree ? :recent_tree : :recent
89
+ session_store.public_send(method, limit: limit, keep_empty_path: live_session_paths)
90
+ end
91
+
83
92
  def cleanup_replaced_session(previous_session)
84
93
  return unless previous_session
85
94
  return if @active_session && File.expand_path(previous_session.path) == File.expand_path(@active_session.path)
@@ -590,8 +599,8 @@ module Kward
590
599
  end
591
600
 
592
601
  def insert_session_copy(session_store, sessions, labels, source, copy_path)
593
- copy_info = session_store.recent_tree(limit: nil).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
594
- copy_info ||= session_store.recent(limit: nil).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
602
+ copy_info = recent_sessions(session_store, tree: true).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
603
+ copy_info ||= recent_sessions(session_store).find { |session| File.expand_path(session.path) == File.expand_path(copy_path) }
595
604
  return nil unless copy_info
596
605
 
597
606
  source_index = sessions.index(source) || 0
@@ -618,7 +627,7 @@ module Kward
618
627
  return nil unless source
619
628
 
620
629
  session_store.load(source.path).first.rename(name)
621
- updated = session_store.recent_tree(limit: nil)
630
+ updated = recent_sessions(session_store, tree: true)
622
631
  sessions.replace(updated)
623
632
  labels.replace(session_picker_labels(sessions))
624
633
  index = sessions.index { |session| File.expand_path(session.path) == File.expand_path(source.path) } || 0
@@ -716,7 +725,7 @@ module Kward
716
725
  end
717
726
 
718
727
  def select_session_path(session_store)
719
- select_session_path_from_sessions(session_store.recent_tree(limit: nil), session_store: session_store)
728
+ select_session_path_from_sessions(recent_sessions(session_store, tree: true), session_store: session_store)
720
729
  end
721
730
 
722
731
  def reopen_sessions_after_fork(session_store, source_path, source_label)
@@ -724,7 +733,7 @@ module Kward
724
733
  fork_session_from_picker(session_store, source_path)
725
734
  end
726
735
 
727
- sessions = session_store.recent_tree(limit: nil)
736
+ sessions = recent_sessions(session_store, tree: true)
728
737
  labels = session_picker_labels(sessions)
729
738
  initial_index = if fork_path
730
739
  sessions.index { |session| File.expand_path(session.path) == File.expand_path(fork_path) }
@@ -243,6 +243,8 @@ module Kward
243
243
  next unless width
244
244
 
245
245
  update_overlay_settings("width" => width)
246
+ when /\Afile icons/
247
+ configure_project_browser_icon_theme
246
248
  when /\Ashow busy help/, /\Ahide busy help/
247
249
  set_composer_busy_help(!composer_busy_help?)
248
250
  runtime_output("Busy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.")
@@ -277,6 +279,7 @@ module Kward
277
279
  [
278
280
  "Overlay alignment (#{settings["alignment"]})",
279
281
  "Overlay width (#{settings["width"]})",
282
+ "File icons (#{project_browser_icon_theme})",
280
283
  "#{composer_busy_help? ? "Hide" : "Show"} busy help (currently #{on_off(composer_busy_help?)})",
281
284
  "Tab keybindings (#{composer_tab_keybindings})",
282
285
  "Editor mode (#{editor_mode})",
@@ -290,6 +293,25 @@ module Kward
290
293
  ]
291
294
  end
292
295
 
296
+ def project_browser_icon_theme
297
+ ConfigFiles.project_browser_icon_theme(safely_read_config.to_h)
298
+ end
299
+
300
+ def configure_project_browser_icon_theme
301
+ selected = @prompt.select("File icons", project_browser_icon_theme_choices, title: "Settings")
302
+ theme = selected.to_s.split.first.to_s.downcase
303
+ return unless ConfigFiles::PROJECT_BROWSER_ICON_THEMES.include?(theme)
304
+
305
+ update_nested_config("project_browser", "icons" => theme)
306
+ @prompt.update_project_browser_icon_theme(theme) if @prompt.respond_to?(:update_project_browser_icon_theme)
307
+ runtime_output("File icons set to #{theme}.")
308
+ end
309
+
310
+ def project_browser_icon_theme_choices
311
+ current = project_browser_icon_theme
312
+ ConfigFiles::PROJECT_BROWSER_ICON_THEMES.map { |theme| theme == current ? "#{theme} (current)" : theme }
313
+ end
314
+
293
315
  def composer_busy_help?
294
316
  ConfigFiles.composer_busy_help?(safely_read_config.to_h)
295
317
  end