kward 0.70.0 → 0.72.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -7,6 +7,7 @@ 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)
10
11
  prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
11
12
  print_user_transcript(input, display_input: display_input) if prompt_interface?
12
13
  return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
@@ -42,7 +43,7 @@ module Kward
42
43
 
43
44
  while worker.alive?
44
45
  begin
45
- poll_result = collect_busy_input(queued_inputs, steering)
46
+ poll_result = collect_busy_input(queued_inputs, steering, agent)
46
47
  sleep 0.01
47
48
  rescue Interrupt
48
49
  poll_result = PromptInterface::CANCEL_INPUT
@@ -52,7 +53,11 @@ module Kward
52
53
  cancellation.cancel!
53
54
  worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
54
55
  end
55
- drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
56
+ if busy_replacement_agent?
57
+ discard_interactive_events(event_queue, markdown_chunks, stream_state)
58
+ else
59
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
60
+ end
56
61
  end
57
62
  begin
58
63
  worker.join
@@ -60,10 +65,14 @@ module Kward
60
65
  error ||= e
61
66
  end
62
67
  drain_busy_input(queued_inputs, nil) unless cancelled
63
- drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
64
- raise error if error && !error.is_a?(Cancellation::CancelledError)
68
+ if busy_replacement_agent?
69
+ discard_interactive_events(event_queue, markdown_chunks, stream_state, force: true)
70
+ else
71
+ drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
72
+ end
73
+ raise error if error && !error.is_a?(Cancellation::CancelledError) && !busy_replacement_agent?
65
74
 
66
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || stream_state[:streamed] || answer.to_s.empty?
75
+ @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || busy_replacement_agent? || stream_state[:streamed] || answer.to_s.empty?
67
76
  persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
68
77
  auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
69
78
  queued_inputs
@@ -87,6 +96,21 @@ module Kward
87
96
  flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
88
97
  end
89
98
 
99
+ def discard_interactive_events(event_queue, markdown_chunks, stream_state, force: false)
100
+ drained = 0
101
+ loop do
102
+ break if !force && drained >= INTERACTIVE_EVENT_DRAIN_LIMIT
103
+
104
+ event_queue.pop(true)
105
+ drained += 1
106
+ rescue ThreadError
107
+ break
108
+ end
109
+ markdown_chunks.clear
110
+ finish_stream_block if stream_state[:stream_block_open]
111
+ stream_state[:stream_block_open] = false
112
+ end
113
+
90
114
  def handle_interactive_event(event, markdown_chunks, stream_state)
91
115
  case event
92
116
  when Events::ReasoningDelta
@@ -154,13 +178,18 @@ module Kward
154
178
  collect_busy_input(queued_inputs, nil)
155
179
  end
156
180
 
157
- def collect_busy_input(queued_inputs, steering)
181
+ def collect_busy_input(queued_inputs, steering, agent = nil)
158
182
  return nil if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
159
183
 
160
184
  poll_result = @prompt.poll_input
161
185
  case poll_result
162
186
  when String
163
- if steering && !poll_result.strip.empty?
187
+ return poll_result if handle_busy_worker_input(poll_result, agent, queued_inputs)
188
+
189
+ if slash_command_input?(poll_result)
190
+ queued_inputs << poll_result
191
+ @prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
192
+ elsif steering && !poll_result.strip.empty?
164
193
  begin
165
194
  steering.submit(poll_result)
166
195
  @prompt.set_steered_count(1) if @prompt.respond_to?(:set_steered_count)
@@ -183,16 +212,51 @@ module Kward
183
212
  drain_busy_input(queued_inputs, nil)
184
213
  end
185
214
 
186
- def drain_busy_input(queued_inputs, steering)
215
+ def drain_busy_input(queued_inputs, steering, agent = nil)
187
216
  deadline = Time.now + 0.15
188
217
  loop do
189
- poll_result = collect_busy_input(queued_inputs, steering)
218
+ poll_result = collect_busy_input(queued_inputs, steering, agent)
190
219
  break if Time.now > deadline && poll_result.nil?
191
220
 
192
221
  sleep 0.01
193
222
  end
194
223
  end
195
224
 
225
+ def slash_command_input?(input)
226
+ input.to_s.strip.start_with?("/")
227
+ end
228
+
229
+ def handle_busy_worker_input(input, agent, queued_inputs)
230
+ return false unless agent
231
+
232
+ command = input.to_s.strip
233
+ return false unless command == "/workers" || command.start_with?("/workers ")
234
+
235
+ _handled, replacement_agent = handle_local_slash_command(command, agent, @session_store)
236
+ @busy_replacement_agent = replacement_agent if replacement_agent?(replacement_agent)
237
+ restore_busy_input_prompt
238
+ true
239
+ rescue StandardError => e
240
+ runtime_output("Error: #{e.message}")
241
+ restore_busy_input_prompt
242
+ true
243
+ end
244
+
245
+ def replacement_agent?(object)
246
+ object.respond_to?(:conversation) && object.respond_to?(:ask)
247
+ end
248
+
249
+ def busy_replacement_agent?
250
+ replacement_agent?(@busy_replacement_agent)
251
+ end
252
+
253
+ def restore_busy_input_prompt
254
+ return unless @prompt.respond_to?(:begin_busy_input)
255
+ return if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
256
+
257
+ @prompt.begin_busy_input("You>")
258
+ end
259
+
196
260
  def steering_supported?
197
261
  @client.respond_to?(:supports_in_flight_steer?) && @client.supports_in_flight_steer?
198
262
  end
@@ -0,0 +1,55 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # OpenRouter cache management commands for the terminal CLI flow.
6
+ module OpenRouterCommands
7
+ private
8
+
9
+ def handle_openrouter_command(arguments)
10
+ case arguments
11
+ when ["refresh"], ["--refresh"]
12
+ refresh_openrouter_models
13
+ when ["list"], ["--list"]
14
+ list_openrouter_models
15
+ else
16
+ raise ArgumentError, command_usage("openrouter")
17
+ end
18
+ end
19
+
20
+ def refresh_openrouter_models
21
+ cache = OpenRouterModelCache.new(api_key: configured_openrouter_api_key, path: openrouter_models_cache_path)
22
+ data = cache.refresh
23
+ count = Array(data["models"]).length
24
+ @client.reload_config if @client.respond_to?(:reload_config)
25
+ @prompt.say("Refreshed #{count} OpenRouter text model#{count == 1 ? "" : "s"} for this key.")
26
+ @prompt.say("Cached at: #{cache.path}")
27
+ rescue StandardError => e
28
+ warn e.message
29
+ exit 1
30
+ end
31
+
32
+ def list_openrouter_models
33
+ cache = OpenRouterModelCache.new(api_key: configured_openrouter_api_key, path: openrouter_models_cache_path)
34
+ data = cache.read
35
+ unless data
36
+ @prompt.say("No OpenRouter model cache found. Run `kward openrouter refresh` first.")
37
+ return
38
+ end
39
+
40
+ models = Array(data["models"])
41
+ lines = ["OpenRouter models cached at #{data["refreshed_at"]}:"]
42
+ lines.concat(models.map { |model| model["id"].to_s }.reject(&:empty?))
43
+ @prompt.say(lines.join("\n"))
44
+ end
45
+
46
+ def configured_openrouter_api_key
47
+ ENV["OPENROUTER_API_KEY"].to_s.empty? ? ConfigFiles.config_value(ConfigFiles.read_config, "openrouter_api_key").to_s : ENV["OPENROUTER_API_KEY"].to_s
48
+ end
49
+
50
+ def openrouter_models_cache_path
51
+ File.join(File.dirname(ConfigFiles.config_path), "cache", "openrouter_models.json")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -7,7 +7,7 @@ module Kward
7
7
  private
8
8
 
9
9
  def prompt_templates
10
- @prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
10
+ @prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands: builtin_slash_command_names)
11
11
  end
12
12
 
13
13
  def plugin_registry
@@ -22,6 +22,14 @@ module Kward
22
22
  plugin_registry.command_for(command)
23
23
  end
24
24
 
25
+ def interactive_commands
26
+ plugin_registry.interactive_commands
27
+ end
28
+
29
+ def interactive_command_for(command)
30
+ plugin_registry.interactive_command_for(command)
31
+ end
32
+
25
33
  def reload_plugins(conversation)
26
34
  @plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
27
35
  conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
@@ -30,7 +38,34 @@ module Kward
30
38
  end
31
39
 
32
40
  def reserved_slash_command_names
33
- BUILTIN_SLASH_COMMAND_NAMES + prompt_templates.map(&:command)
41
+ builtin_slash_command_names + prompt_templates.map(&:command)
42
+ end
43
+
44
+ def run_interactive_command(name, argument, agent)
45
+ command = interactive_command_for(name)
46
+ return [false, nil] unless command
47
+ return [false, nil] unless prompt_interface? && @prompt.respond_to?(:start_interactive)
48
+
49
+ context = plugin_context(agent.conversation, argument)
50
+ controller = @prompt.start_interactive(title: "/#{name}", rows: command.rows, fps: command.fps)
51
+ command.handler.call(controller, context)
52
+ run_interactive_loop
53
+ [true, nil]
54
+ rescue StandardError => e
55
+ @prompt.finish_interactive if @prompt.respond_to?(:finish_interactive)
56
+ runtime_output("Interactive command /#{name} error: #{e.message}")
57
+ [true, nil]
58
+ end
59
+
60
+ def run_interactive_loop
61
+ loop do
62
+ result = @prompt.poll_input
63
+ if result == :interactive_exited || @prompt.interactive_exited?
64
+ @prompt.finish_interactive
65
+ break
66
+ end
67
+ sleep 0.01
68
+ end
34
69
  end
35
70
 
36
71
  def slash_command_entries
@@ -42,15 +77,30 @@ module Kward
42
77
  }
43
78
  end
44
79
  plugin_entries = plugin_commands.map(&:entry)
45
- BUILTIN_SLASH_COMMANDS + prompt_entries + plugin_entries
80
+ interactive_entries = interactive_commands.map(&:entry)
81
+ builtin_slash_commands + prompt_entries + plugin_entries + interactive_entries
46
82
  end
47
83
 
48
84
  def prompt_template_for(command)
49
85
  prompt_templates.find { |template| template.command == command }
50
86
  end
51
87
 
88
+ def builtin_slash_commands
89
+ return BUILTIN_SLASH_COMMANDS if experimental_workers_enabled?
90
+
91
+ BUILTIN_SLASH_COMMANDS.reject { |command| command[:name] == "workers" }
92
+ end
93
+
94
+ def builtin_slash_command_names
95
+ builtin_slash_commands.map { |command| command[:name] }
96
+ end
97
+
98
+ def experimental_workers_enabled?
99
+ @experimental_workers == true
100
+ end
101
+
52
102
  def expand_prompt_template(input)
53
- PromptCommands.expand(input, templates: prompt_templates, reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
103
+ PromptCommands.expand(input, templates: prompt_templates, reserved_commands: builtin_slash_command_names)
54
104
  end
55
105
 
56
106
  def run_plugin_command(name, argument, agent)
@@ -1,3 +1,5 @@
1
+ require "open3"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
@@ -13,7 +15,6 @@ module Kward
13
15
  prompt_interface = load_prompt_interface
14
16
  return unless prompt_interface
15
17
 
16
- banner_enabled = ConfigFiles.banner_enabled?
17
18
  @prompt = prompt_interface.new(
18
19
  slash_commands: slash_command_entries,
19
20
  overlay_settings: ConfigFiles.overlay_settings,
@@ -22,8 +23,21 @@ module Kward
22
23
  busy_help: ConfigFiles.composer_busy_help?,
23
24
  attachment_badges: method(:composer_attachment_badges),
24
25
  attachment_parser: method(:composer_attachment_parser),
25
- banner_pixels: banner_enabled ? Kward::PromptInterface::BANNER_LOGO_PIXELS : nil,
26
- banner_message: banner_enabled ? Kward::PromptInterface::BANNER_MESSAGE : nil
26
+ banner_message: Kward::PromptInterface::BANNER_MESSAGE,
27
+ tab_keybindings: ConfigFiles.composer_tab_keybindings,
28
+ prompt_history: PromptHistory.new(cwd: current_workspace_root),
29
+ editor_mode: ConfigFiles.editor_mode,
30
+ editor_mode_source: -> { ConfigFiles.editor_mode },
31
+ editor_auto_indent: ConfigFiles.editor_auto_indent?,
32
+ editor_auto_indent_source: -> { ConfigFiles.editor_auto_indent? },
33
+ editor_auto_close_pairs: ConfigFiles.editor_auto_close_pairs?,
34
+ editor_auto_close_pairs_source: -> { ConfigFiles.editor_auto_close_pairs? },
35
+ editor_soft_wrap: ConfigFiles.editor_soft_wrap?,
36
+ editor_soft_wrap_source: -> { ConfigFiles.editor_soft_wrap? },
37
+ editor_bar_cursor: ConfigFiles.editor_bar_cursor?,
38
+ editor_bar_cursor_source: -> { ConfigFiles.editor_bar_cursor? },
39
+ editor_line_numbers: ConfigFiles.editor_line_numbers,
40
+ editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers }
27
41
  )
28
42
  if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
29
43
  @prompt.start(render: false)
@@ -50,9 +64,80 @@ module Kward
50
64
  @prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
51
65
  end
52
66
 
53
- # Writes the visual banner output for the terminal CLI flow.
67
+ # Writes the startup info screen output for the terminal CLI flow.
54
68
  def print_visual_banner
55
- @prompt.print_visual_banner if @prompt.respond_to?(:print_visual_banner)
69
+ return unless @prompt.respond_to?(:print_visual_banner)
70
+
71
+ @prompt.print_visual_banner(startup_info_screen)
72
+ end
73
+
74
+ def startup_info_screen
75
+ [
76
+ startup_status_line,
77
+ "",
78
+ startup_info_line("Workspace", startup_workspace_label),
79
+ startup_info_line("Branch", startup_branch_value),
80
+ startup_info_line("Plugins", startup_plugins_value),
81
+ "",
82
+ startup_brand_line
83
+ ].join("\n")
84
+ end
85
+
86
+ def startup_workspace_label
87
+ root = File.expand_path(current_workspace_root)
88
+ home = begin
89
+ Dir.home
90
+ rescue StandardError
91
+ nil
92
+ end
93
+ if home && (root == home || root.start_with?("#{home}/"))
94
+ relative = root.delete_prefix(home).sub(%r{\A/}, "")
95
+ return "~" if relative.empty?
96
+ return "~/#{relative}" unless relative.include?("/")
97
+ end
98
+
99
+ parent = File.basename(File.dirname(root))
100
+ name = File.basename(root)
101
+ parent.empty? || parent == "." ? name : "#{parent}/#{name}"
102
+ end
103
+
104
+ def startup_branch_value
105
+ git_root = startup_git_root(current_workspace_root)
106
+ return "not a repository" if git_root.to_s.empty?
107
+
108
+ branch = startup_git_output(%w[git branch --show-current], root: git_root)
109
+ branch = startup_git_output(%w[git rev-parse --short HEAD], root: git_root) if branch.empty?
110
+ branch.empty? ? "unknown" : branch
111
+ end
112
+
113
+ def startup_plugins_value
114
+ filenames = plugin_registry.paths.map { |path| File.basename(path) }
115
+ filenames.empty? ? "none" : filenames.join(", ")
116
+ end
117
+
118
+ def startup_status_line
119
+ "#{ANSI.colorize("●", :green, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
120
+ end
121
+
122
+ def startup_info_line(label, value)
123
+ "#{ANSI.colorize(label.ljust(12), :gray, enabled: @color_enabled)}#{ANSI.colorize(value, :cyan, enabled: @color_enabled)}"
124
+ end
125
+
126
+ def startup_brand_line
127
+ ANSI.colorize(Kward::PromptInterface::BANNER_MESSAGE, :bold, enabled: @color_enabled)
128
+ end
129
+
130
+ def startup_git_root(root)
131
+ startup_git_output(%w[git rev-parse --show-toplevel], root: root)
132
+ end
133
+
134
+ def startup_git_output(command, root:)
135
+ output, status = Open3.capture2e(*command, chdir: root.to_s)
136
+ return "" unless status.success?
137
+
138
+ output.lines.first.to_s.strip
139
+ rescue StandardError
140
+ ""
56
141
  end
57
142
 
58
143
  def prompt_footer_renderer
@@ -78,6 +163,8 @@ module Kward
78
163
  reasoning = "n/a" unless ModelInfo.reasoning_supported?(provider, model) && !reasoning.to_s.empty?
79
164
  text = "#{provider} #{model} · #{reasoning}"
80
165
  parts = []
166
+ git = composer_git_branch_text
167
+ parts << git if git
81
168
  diff = composer_session_diff_text
82
169
  parts << diff if diff
83
170
  usage = composer_context_usage(provider, model)
@@ -94,6 +181,21 @@ module Kward
94
181
  "#{additions}|#{deletions}"
95
182
  end
96
183
 
184
+ def composer_git_branch_text
185
+ git_root = startup_git_root(current_workspace_root)
186
+ return nil if git_root.to_s.empty?
187
+
188
+ branch = startup_git_output(%w[git branch --show-current], root: git_root)
189
+ branch = startup_git_output(%w[git rev-parse --short HEAD], root: git_root) if branch.empty?
190
+ branch = "unknown" if branch.empty?
191
+ color = composer_git_dirty?(git_root) ? :yellow : nil
192
+ ANSI.colorize(branch, color, enabled: @color_enabled)
193
+ end
194
+
195
+ def composer_git_dirty?(git_root)
196
+ !startup_git_output(%w[git status --porcelain --untracked-files=normal], root: git_root).empty?
197
+ end
198
+
97
199
  def composer_context_percent_text(percent)
98
200
  value = percent.round
99
201
  color = if value >= 85
@@ -107,7 +209,10 @@ module Kward
107
209
  def composer_context_window(provider = nil, model = nil)
108
210
  provider ||= current_footer_conversation.provider || (@client.respond_to?(:current_provider) ? @client.current_provider : "Codex")
109
211
  model ||= current_footer_conversation.model || (@client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL)
110
- ModelInfo.context_window(ModelInfo.provider_label(provider), model)
212
+ provider = ModelInfo.provider_label(provider)
213
+ return @client.context_window(provider, model) if @client.respond_to?(:context_window) && @client.method(:context_window).arity != 0
214
+
215
+ ModelInfo.context_window(provider, model)
111
216
  end
112
217
 
113
218
  def composer_context_usage(provider, model)
@@ -59,7 +59,7 @@ module Kward
59
59
  if prompt_interface?
60
60
  print_tool_result(tool_call, content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
61
61
  else
62
- @prompt.say("\n#{colored("Tool>", *tool_label_styles(content))}\n#{summary}\n")
62
+ @prompt.say("\n#{colored("Tool>", *tool_label_styles(content))} #{tool_summary_display_text(summary)}\n")
63
63
  end
64
64
  end
65
65
 
@@ -71,7 +71,7 @@ module Kward
71
71
  print_block_delta(label, rendered)
72
72
  finish_stream_block
73
73
  else
74
- @prompt.say("\n#{colored("#{transcript_label(label)}>", *label_styles(label))}\n#{rendered}\n")
74
+ @prompt.say("\n#{colored("#{transcript_label(label)}>", *label_styles(label))} #{rendered}\n")
75
75
  end
76
76
  end
77
77
 
@@ -298,8 +298,9 @@ module Kward
298
298
  def print_tool_result(tool_call, content, line_limit: nil)
299
299
  summary = tool_result_summary(tool_call, content)
300
300
  summary = limit_tool_output_lines(summary, line_limit) if line_limit
301
+ display_summary = tool_summary_display_text(summary)
301
302
  if prompt_interface?
302
- summary = summary.end_with?("\n") ? summary : "#{summary}\n"
303
+ summary = display_summary.end_with?("\n") ? display_summary : "#{display_summary}\n"
303
304
  if @prompt.respond_to?(:write_stream_block)
304
305
  @prompt.write_stream_block("Tool", summary, finish: true)
305
306
  else
@@ -309,18 +310,22 @@ module Kward
309
310
  end
310
311
  else
311
312
  start_stream_block(tool_stream_label(content))
312
- print summary
313
- puts unless summary.end_with?("\n")
313
+ print display_summary
314
+ puts unless display_summary.end_with?("\n")
314
315
  $stdout.flush
315
316
  @stream_block = nil
316
317
  end
317
318
  end
318
319
 
320
+ def tool_summary_display_text(summary)
321
+ summary.to_s.sub("\n", "\n\n")
322
+ end
323
+
319
324
  def start_stream_block(label)
320
325
  return if @stream_block == label
321
326
 
322
327
  puts if @stream_block
323
- puts "\n#{colored("#{transcript_label(label)}>", *label_styles(label))}"
328
+ print "\n#{colored("#{transcript_label(label)}>", *label_styles(label))} "
324
329
  @stream_block = label
325
330
  end
326
331
 
@@ -43,9 +43,22 @@ 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")
46
52
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
47
53
  workspace = configured_workspace(root: conversation.workspace_root)
48
- tool_registry = ToolRegistry.new(workspace: workspace, prompt: @prompt)
54
+ writer_id = worker_writer_id(role)
55
+ tool_registry = ToolRegistry.new(
56
+ workspace: workspace,
57
+ prompt: @prompt,
58
+ allowed_tool_names: Workers::ToolPolicy.allowed_tool_names(role),
59
+ write_lock: @worker_write_lock,
60
+ writer_id: writer_id
61
+ )
49
62
  @footer_conversation = conversation
50
63
  @footer_tool_registry = tool_registry
51
64
  Agent.new(
@@ -55,6 +68,34 @@ module Kward
55
68
  )
56
69
  end
57
70
 
71
+ def set_visible_worker(id, status: nil, worker: nil)
72
+ @visible_worker_id = id.to_s
73
+ @visible_worker_status = status
74
+ @visible_worker = worker
75
+ end
76
+
77
+ def worker_writer_id(role)
78
+ return nil unless Workers::ToolPolicy.write_capable?(role)
79
+
80
+ @worker_write_lock ||= Workers::WriteLock.new
81
+ owner_id = role.to_s.empty? ? "implementation" : role.to_s
82
+ return owner_id if @worker_write_lock.acquire(owner_id)
83
+
84
+ nil
85
+ end
86
+
87
+ def refresh_implementation_writer(agent)
88
+ return agent unless @active_worker_role == "implementation"
89
+ return agent unless agent&.respond_to?(:tool_registry)
90
+ return agent if agent.tool_registry.writer_id && @worker_write_lock&.owned_by?(agent.tool_registry.writer_id)
91
+
92
+ build_interactive_agent(agent.conversation)
93
+ end
94
+
95
+ def release_implementation_writer
96
+ @worker_write_lock&.release("implementation")
97
+ end
98
+
58
99
  def handle_interactive_shell_command(input, agent)
59
100
  command = input.to_s.sub(/\A!\s*/, "")
60
101
  if command.strip.empty?
@@ -73,6 +114,95 @@ module Kward
73
114
  input.to_s.start_with?("!")
74
115
  end
75
116
 
117
+ def run_ekwsh(agent)
118
+ unless @prompt.respond_to?(:ask)
119
+ runtime_output("The embedded shell is only available in interactive mode.")
120
+ return
121
+ end
122
+
123
+ tab = active_tab if respond_to?(:active_tab, true)
124
+ entering = tab.nil? || tab.shell.nil?
125
+ shell = tab&.shell || build_ekwsh(agent)
126
+ tab.shell = shell if tab
127
+ runtime_output("Entering ekwsh. Type exit or press Ctrl+D on an empty prompt to return.") if entering
128
+ run_ekwsh_loop(shell, tab: tab)
129
+ end
130
+
131
+ def build_ekwsh(agent)
132
+ config = ConfigFiles.read_ekwsh_config
133
+ Ekwsh.new(cwd: interactive_workspace_root(agent), configured_env: config[:env], aliases: config[:aliases])
134
+ end
135
+
136
+ def run_ekwsh_loop(shell, tab: nil)
137
+ loop do
138
+ if @prompt.respond_to?(:editing_file?) && @prompt.editing_file?
139
+ editor_result = @prompt.run_editor
140
+ if editor_result.is_a?(Hash) && editor_result[:tab_action]
141
+ (@pending_inputs ||= []).unshift(editor_result)
142
+ return :tab_action
143
+ end
144
+ end
145
+
146
+ input = ask_ekwsh(shell)
147
+ if input.is_a?(Hash) && input[:tab_action]
148
+ (@pending_inputs ||= []).unshift(input)
149
+ return :tab_action
150
+ end
151
+ break if input.nil?
152
+
153
+ result = run_ekwsh_command(shell, input)
154
+ @prompt.clear_transcript if result.clear && @prompt.respond_to?(:clear_transcript)
155
+ @prompt.say(result.output) unless result.output.to_s.empty?
156
+ if result.open_editor_path
157
+ editor_result = open_ekwsh_editor(result.open_editor_path, shell)
158
+ return :tab_action if editor_result == :tab_action
159
+
160
+ next
161
+ end
162
+ if result.exit_shell
163
+ tab.shell = nil if tab
164
+ runtime_output("Shell exited.")
165
+ return :exited
166
+ end
167
+ end
168
+ tab.shell = nil if tab
169
+ runtime_output("Shell exited.")
170
+ :exited
171
+ end
172
+
173
+ def open_ekwsh_editor(path, shell)
174
+ unless @prompt.respond_to?(:edit_file)
175
+ runtime_output("Integrated editor is unavailable in this prompt.")
176
+ return false
177
+ end
178
+
179
+ result = @prompt.edit_file(path, base_dir: shell.cwd, allow_new: true)
180
+ if result.is_a?(Hash) && result[:tab_action]
181
+ (@pending_inputs ||= []).unshift(result)
182
+ return :tab_action
183
+ end
184
+
185
+ result
186
+ end
187
+
188
+ def ask_ekwsh(shell)
189
+ provider = ->(input, cursor) { shell.complete(input, cursor) }
190
+ if @prompt.respond_to?(:with_completion_provider)
191
+ @prompt.with_completion_provider(provider) { @prompt.ask(shell.prompt_label) }
192
+ else
193
+ @prompt.ask(shell.prompt_label)
194
+ end
195
+ end
196
+
197
+ def run_ekwsh_command(shell, input)
198
+ if @prompt.respond_to?(:begin_busy_input)
199
+ @prompt.begin_busy_input(shell.prompt_label, activity: "running")
200
+ end
201
+ shell.run(input)
202
+ ensure
203
+ @prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
204
+ end
205
+
76
206
  def configured_workspace(root: current_workspace_root)
77
207
  Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
78
208
  end
@@ -146,10 +276,10 @@ module Kward
146
276
  @client.reload_config if @client.respond_to?(:reload_config)
147
277
  end
148
278
 
149
- def refresh_conversation_runtime(conversation)
279
+ def refresh_conversation_runtime(conversation, reasoning_effort: current_reasoning_effort, refresh_system_message: true)
150
280
  return unless conversation&.respond_to?(:update_runtime_context!)
151
281
 
152
- conversation.update_runtime_context!(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
282
+ conversation.update_runtime_context!(provider: current_model_provider, model: current_model_id, reasoning_effort: reasoning_effort, refresh: refresh_system_message)
153
283
  update_assistant_prompt(conversation)
154
284
  end
155
285