kward 0.83.0 → 0.84.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -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/api.md +4 -0
  9. data/doc/composer.md +1 -1
  10. data/doc/configuration.md +68 -21
  11. data/doc/editor.md +28 -13
  12. data/doc/files.md +8 -4
  13. data/doc/getting-started.md +3 -0
  14. data/doc/pan.md +19 -15
  15. data/doc/platform-support.md +48 -0
  16. data/doc/security.md +3 -2
  17. data/doc/shell.md +62 -45
  18. data/doc/troubleshooting.md +12 -2
  19. data/doc/usage.md +5 -5
  20. data/kward.gemspec +5 -4
  21. data/lib/kward/agent.rb +6 -3
  22. data/lib/kward/ansi.rb +110 -10
  23. data/lib/kward/cli/auth_commands.rb +34 -13
  24. data/lib/kward/cli/commands.rb +83 -62
  25. data/lib/kward/cli/doctor.rb +39 -17
  26. data/lib/kward/cli/hook_commands.rb +22 -12
  27. data/lib/kward/cli/interactive_turn.rb +48 -7
  28. data/lib/kward/cli/project_skills_commands.rb +8 -4
  29. data/lib/kward/cli/prompt_interface.rb +27 -0
  30. data/lib/kward/cli/rendering.rb +15 -9
  31. data/lib/kward/cli/runtime_helpers.rb +118 -55
  32. data/lib/kward/cli/slash_commands.rb +12 -14
  33. data/lib/kward/cli/tabs.rb +83 -12
  34. data/lib/kward/cli/tool_summaries.rb +14 -0
  35. data/lib/kward/cli.rb +45 -7
  36. data/lib/kward/cli_transcript_formatter.rb +11 -4
  37. data/lib/kward/config_files.rb +82 -68
  38. data/lib/kward/detached_run.rb +44 -0
  39. data/lib/kward/interactive_pty_runner.rb +102 -28
  40. data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
  41. data/lib/kward/kwshrc.rb +233 -0
  42. data/lib/kward/markdown_code_block.rb +136 -0
  43. data/lib/kward/model/client.rb +34 -22
  44. data/lib/kward/model/provider_catalog.rb +5 -0
  45. data/lib/kward/model/stream_parser.rb +20 -4
  46. data/lib/kward/pan/index.html.erb +3 -3
  47. data/lib/kward/pan/server.rb +23 -3
  48. data/lib/kward/persistent_shell_session.rb +119 -26
  49. data/lib/kward/project_files.rb +2 -2
  50. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  51. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  52. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  53. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  54. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  55. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  56. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  57. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  58. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  59. data/lib/kward/prompt_interface/editor/state.rb +10 -10
  60. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  61. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  62. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  63. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  64. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  65. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  66. data/lib/kward/prompt_interface/runtime_state.rb +50 -1
  67. data/lib/kward/prompt_interface/screen.rb +2 -2
  68. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  69. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  70. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  71. data/lib/kward/prompt_interface.rb +93 -18
  72. data/lib/kward/prompts/commands.rb +1 -1
  73. data/lib/kward/prompts.rb +1 -1
  74. data/lib/kward/pty_output_sink.rb +47 -0
  75. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  76. data/lib/kward/scratchpad_languages.rb +74 -0
  77. data/lib/kward/scratchpad_runner.rb +155 -29
  78. data/lib/kward/shell_prompt.rb +2 -0
  79. data/lib/kward/terminal_keys.rb +12 -0
  80. data/lib/kward/terminal_text.rb +121 -0
  81. data/lib/kward/text_matcher.rb +18 -0
  82. data/lib/kward/tools/open_editor.rb +41 -0
  83. data/lib/kward/tools/registry.rb +13 -4
  84. data/lib/kward/tools/tool_call.rb +2 -1
  85. data/lib/kward/version.rb +1 -1
  86. data/templates/default/fulldoc/html/css/kward.css +0 -125
  87. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  88. data/templates/default/fulldoc/html/setup.rb +1 -1
  89. data/templates/default/kward_navigation.rb +1 -0
  90. data/templates/default/layout/html/footer.erb +10 -0
  91. data/templates/default/layout/html/headers.erb +23 -0
  92. data/templates/default/layout/html/layout.erb +6 -18
  93. data/templates/default/layout/html/setup.rb +41 -2
  94. metadata +36 -8
  95. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -33,9 +33,11 @@ module Kward
33
33
  :markdown_chunks,
34
34
  :label,
35
35
  :unread,
36
+ :attention,
36
37
  :pending_question,
37
38
  :shell,
38
39
  :shell_agent,
40
+ :background_run,
39
41
  :transient_shell_entries,
40
42
  :error_reported,
41
43
  :local_busy_activity,
@@ -106,6 +108,10 @@ module Kward
106
108
  @cli.send(:ask_tab_user_question, @tab, questions, cancellation: cancellation)
107
109
  end
108
110
 
111
+ def edit_file(path, base_dir:, allow_new:)
112
+ @cli.send(:open_editor_for_agent, path, base_dir: base_dir, allow_new: allow_new)
113
+ end
114
+
109
115
  def ask_tool_approval(tool_name:, args:, reason: nil)
110
116
  answers = ask_user_question([
111
117
  {
@@ -316,9 +322,11 @@ module Kward
316
322
  markdown_chunks: [],
317
323
  label: label,
318
324
  unread: false,
325
+ attention: nil,
319
326
  pending_question: nil,
320
327
  shell: nil,
321
328
  shell_agent: nil,
329
+ background_run: nil,
322
330
  transient_shell_entries: [],
323
331
  error_reported: false,
324
332
  local_busy_activity: nil
@@ -420,14 +428,14 @@ module Kward
420
428
  end
421
429
 
422
430
  if @tabs.length <= 1
423
- close_ekwsh_session(tab.shell) if tab&.shell && respond_to?(:close_ekwsh_session, true)
431
+ close_kwsh_session(tab.shell) if tab&.shell && respond_to?(:close_kwsh_session, true)
424
432
  @tabs.clear
425
433
  persist_tabs
426
434
  return PromptInterface::EXIT_INPUT
427
435
  end
428
436
 
429
437
  stop_tab_live_view
430
- close_ekwsh_session(tab.shell) if tab&.shell && respond_to?(:close_ekwsh_session, true)
438
+ close_kwsh_session(tab.shell) if tab&.shell && respond_to?(:close_kwsh_session, true)
431
439
  tab.session&.delete_if_unused if tab&.session.respond_to?(:delete_if_unused)
432
440
  tab.driver.close if tab.driver.respond_to?(:close)
433
441
  @tabs.delete_at(@active_tab_index)
@@ -461,12 +469,13 @@ module Kward
461
469
  tab.error = nil
462
470
  tab.answer = nil
463
471
  tab.unread = false
472
+ tab.attention = nil
464
473
  tab.error_reported = false
465
474
  tab.event_history.clear
466
475
  tab.seen_events = 0
467
476
  tab.queued_inputs.clear
468
477
  tab.steering = nil
469
- close_ekwsh_session(tab.shell) if tab.shell && respond_to?(:close_ekwsh_session, true)
478
+ close_kwsh_session(tab.shell) if tab.shell && respond_to?(:close_kwsh_session, true)
470
479
  tab.shell = nil
471
480
  tab.shell_agent = nil
472
481
  tab.clear_transient_shell_entries
@@ -501,6 +510,7 @@ module Kward
501
510
  end
502
511
 
503
512
  def activate_tab(index, render: true)
513
+ service_background_tab_runs
504
514
  tab = @tabs[index]
505
515
  return nil unless tab
506
516
 
@@ -517,6 +527,7 @@ module Kward
517
527
  @prompt.update_assistant_label(assistant_prompt_name) if @prompt.respond_to?(:update_assistant_label)
518
528
  end
519
529
  tab.unread = false
530
+ tab.attention = nil
520
531
  restore_tab_composer_snapshot(tab.snapshot)
521
532
  update_prompt_workspace_root(current_workspace_root)
522
533
  @prompt.refresh_composer_status if @prompt.respond_to?(:refresh_composer_status)
@@ -603,6 +614,44 @@ module Kward
603
614
  index ? index + 1 : active_tab_number
604
615
  end
605
616
 
617
+ def register_background_tab_run(tab, run, &completion)
618
+ return unless tab && run
619
+
620
+ tab.background_run = { run: run, completion: completion }
621
+ tab.local_busy_activity = "running"
622
+ tab.attention = nil
623
+ update_prompt_tabs
624
+ end
625
+
626
+ def service_background_tab_runs
627
+ return unless @tabs
628
+
629
+ @tabs.each do |tab|
630
+ state = tab.background_run
631
+ next unless state&.fetch(:run)&.complete?
632
+
633
+ result = state[:run].result
634
+ tab.background_run = nil
635
+ state[:completion]&.call(state[:run].sink, result) if result.is_a?(Struct)
636
+ finish_local_busy_command_for_tab(tab)
637
+ if tab != active_tab
638
+ tab.unread = true
639
+ tab.attention = :success
640
+ end
641
+ rescue StandardError => e
642
+ tab.background_run = nil
643
+ tab.error = e.message
644
+ finish_local_busy_command_for_tab(tab)
645
+ tab.attention = :failure if tab != active_tab
646
+ end
647
+ update_prompt_tabs
648
+ end
649
+
650
+ def finish_local_busy_command_for_tab(tab)
651
+ tab.local_busy_activity = nil
652
+ @prompt.finish_busy_input if prompt_interface? && tab == active_tab && @prompt.respond_to?(:finish_busy_input)
653
+ end
654
+
606
655
  def submit_tab_input(tab, input, display_input: nil)
607
656
  return if input.to_s.strip.empty?
608
657
 
@@ -616,6 +665,7 @@ module Kward
616
665
  print_user_transcript(input, display_input: display_input) if prompt_interface?
617
666
  tab.status = "queued"
618
667
  tab.unread = false
668
+ tab.attention = nil
619
669
  tab.cancellation = Cancellation.new
620
670
  tab.steering = tab.driver.supports_steering? && steering_supported? ? Steering.new : nil
621
671
  tab.error = nil
@@ -639,14 +689,17 @@ module Kward
639
689
  end
640
690
  tab.status = "ready"
641
691
  tab.unread = tab != active_tab
692
+ tab.attention = :output if tab != active_tab
642
693
  rescue Cancellation::CancelledError
643
694
  tab.status = "cancelled"
644
695
  tab.unread = false
696
+ tab.attention = :failure if tab != active_tab
645
697
  report_tab_runtime_error(tab)
646
698
  rescue StandardError => e
647
699
  tab.error = e.message
648
700
  tab.status = "failed"
649
701
  tab.unread = false
702
+ tab.attention = :failure if tab != active_tab
650
703
  report_tab_runtime_error(tab)
651
704
  ensure
652
705
  finish_tab_turn(tab)
@@ -662,8 +715,9 @@ module Kward
662
715
  end
663
716
 
664
717
  def poll_active_tab_input
718
+ service_background_tab_runs
665
719
  tab = active_tab
666
- unless tab&.running?
720
+ unless tab&.running? || tab&.local_busy?
667
721
  input = @prompt.ask("You>")
668
722
  return { tab_action: :close } if input.nil? && tab && @tabs.length > 1
669
723
 
@@ -672,6 +726,7 @@ module Kward
672
726
 
673
727
  @prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
674
728
  loop do
729
+ service_background_tab_runs
675
730
  refresh_active_tab
676
731
  if service_active_tab_question
677
732
  return next_tab_queued_input(tab) if tab.idle? && !tab.queued_inputs.empty?
@@ -679,6 +734,10 @@ module Kward
679
734
 
680
735
  next
681
736
  end
737
+ if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
738
+ sleep 0.01
739
+ next
740
+ end
682
741
  poll_result = @prompt.poll_input
683
742
  case poll_result
684
743
  when Hash
@@ -687,8 +746,12 @@ module Kward
687
746
  return poll_result
688
747
  end
689
748
  when PromptInterface::CANCEL_INPUT
690
- tab.cancellation&.cancel!
691
- tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
749
+ if tab.background_run
750
+ tab.background_run[:run].cancel!
751
+ else
752
+ tab.cancellation&.cancel!
753
+ tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
754
+ end
692
755
  when PromptInterface::EXIT_INPUT
693
756
  tab.queued_inputs << "/exit"
694
757
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
@@ -830,15 +893,17 @@ module Kward
830
893
  return if tab.stream_state[:streamed]
831
894
  return if tab.answer.to_s.empty?
832
895
 
833
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(tab.answer)}\n")
896
+ @prompt.say("\n#{colored(transcript_marker("Assistant"), :activity, :bold)} #{render_markdown_transcript(tab.answer)}\n")
834
897
  end
835
898
 
836
899
  def new_tab_stream_state(driver)
837
900
  {
838
901
  streamed: false,
902
+ assistant_arrived: false,
839
903
  last_flush: monotonic_now,
840
904
  stream_block_open: false,
841
905
  markdown_streams: {},
906
+ tool_elapsed_ms: {},
842
907
  defer_assistant_streaming: driver.respond_to?(:conversation) && defer_assistant_streaming?(driver.agent)
843
908
  }
844
909
  end
@@ -853,10 +918,11 @@ module Kward
853
918
  end
854
919
 
855
920
  def tab_label_color(tab)
856
- return :green if tab.status.to_s == "waiting_for_question"
857
- return :yellow if tab.running? || tab.local_busy?
858
- return :red if %w[failed cancelled].include?(tab.status.to_s)
859
- return :green if tab.unread
921
+ return :success if tab.status.to_s == "waiting_for_question"
922
+ return :caution if tab.running? || tab.local_busy?
923
+ return :failure if %w[failed cancelled].include?(tab.status.to_s) || tab.attention == :failure
924
+ return :success if tab.attention == :success
925
+ return :activity if tab.unread
860
926
 
861
927
  nil
862
928
  end
@@ -970,7 +1036,12 @@ module Kward
970
1036
  tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
971
1037
  tab.thread&.join(0.2)
972
1038
  end
973
- close_ekwsh_session(tab.shell) if tab&.shell && respond_to?(:close_ekwsh_session, true)
1039
+ if (background_run = tab.background_run&.fetch(:run))
1040
+ background_run.cancel!
1041
+ background_run.join(0.5)
1042
+ end
1043
+ tab.background_run = nil
1044
+ close_kwsh_session(tab.shell) if tab&.shell && respond_to?(:close_kwsh_session, true)
974
1045
  rescue StandardError
975
1046
  nil
976
1047
  end
@@ -6,6 +6,20 @@ module Kward
6
6
  module ToolSummaries
7
7
  private
8
8
 
9
+ def tool_activity(tool_call)
10
+ "running #{tool_call_name(tool_call).tr("_", " ")}"
11
+ end
12
+
13
+ def tool_summary_with_duration(summary, elapsed_ms)
14
+ return summary.to_s unless elapsed_ms
15
+
16
+ lines = summary.to_s.lines
17
+ first = lines.shift.to_s.chomp
18
+ duration = elapsed_ms < 1_000 ? "#{elapsed_ms.round} ms" : format("%.1f s", elapsed_ms / 1_000.0)
19
+ first = "#{first} · #{duration}"
20
+ lines.empty? ? first : (["#{first}\n"] + lines).join
21
+ end
22
+
9
23
  def tool_result_summary(tool_call, content)
10
24
  name = tool_call_name(tool_call)
11
25
  args = tool_call_args(tool_call)
data/lib/kward/cli.rb CHANGED
@@ -18,7 +18,7 @@ require_relative "cli_transcript_formatter"
18
18
  require_relative "model/context_usage"
19
19
  require_relative "events"
20
20
  require_relative "export_path"
21
- require_relative "ekwsh"
21
+ require_relative "kwsh"
22
22
  require_relative "persistent_shell_session"
23
23
  require_relative "interactive_pty_runner"
24
24
  require_relative "adaptive_pty_output_sink"
@@ -92,7 +92,7 @@ module Kward
92
92
  RESTORED_TOOL_OUTPUT_LIMIT = 2_000
93
93
  INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT = 10
94
94
  STREAM_RENDER_INTERVAL = 0.025
95
- INTERACTIVE_EVENT_DRAIN_LIMIT = 100
95
+ INTERACTIVE_EVENT_DRAIN_LIMIT = 12
96
96
  BUILTIN_SLASH_COMMANDS = PromptCommands::BUILTIN_COMMANDS
97
97
  BUILTIN_SLASH_COMMAND_NAMES = PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES
98
98
 
@@ -156,6 +156,16 @@ module Kward
156
156
  warn e.message
157
157
  warn "Run `kward help` for available commands."
158
158
  exit 1
159
+ rescue Client::AuthenticationError => e
160
+ raise if debug_errors?
161
+
162
+ warn authentication_error_message(e)
163
+ exit 1
164
+ rescue StandardError => e
165
+ raise if debug_errors?
166
+
167
+ warn runtime_error_message(e)
168
+ exit 1
159
169
  ensure
160
170
  ConfigFiles.skip_config = false
161
171
  end
@@ -184,6 +194,30 @@ module Kward
184
194
  MESSAGE
185
195
  end
186
196
 
197
+ def authentication_error_message(error)
198
+ <<~MESSAGE.rstrip
199
+ Authentication required
200
+
201
+ #{error.message}
202
+
203
+ Run `kward doctor` to inspect your setup.
204
+ MESSAGE
205
+ end
206
+
207
+ def runtime_error_message(error)
208
+ <<~MESSAGE.rstrip
209
+ Kward could not complete the request.
210
+
211
+ #{error.message}
212
+
213
+ Set KWARD_DEBUG=1 to show a backtrace.
214
+ MESSAGE
215
+ end
216
+
217
+ def debug_errors?
218
+ ENV["KWARD_DEBUG"] == "1"
219
+ end
220
+
187
221
  def dispatch
188
222
  if @prompt_delimited
189
223
  ConfigFiles.ensure_default_config!
@@ -229,7 +263,7 @@ module Kward
229
263
  raise ArgumentError, command_usage("doctor") unless @argv.length == 1
230
264
 
231
265
  ensure_client!
232
- print_doctor
266
+ exit 1 unless print_doctor
233
267
  return
234
268
  end
235
269
 
@@ -239,7 +273,7 @@ module Kward
239
273
  return
240
274
  end
241
275
 
242
- handle_hooks_command((@argv[1..] || []).join(" "))
276
+ handle_hooks_command((@argv[1..] || []).join(" "), command_line: true)
243
277
  return
244
278
  end
245
279
 
@@ -478,8 +512,9 @@ module Kward
478
512
  install_bang_completion_provider(agent)
479
513
 
480
514
  loop do
481
- if @pending_inputs.empty? && active_tab&.shell
482
- run_ekwsh_loop(active_tab.shell, tab: active_tab, history: build_ekwsh_history(active_tab.agent))
515
+ service_background_tab_runs if respond_to?(:service_background_tab_runs, true)
516
+ if @pending_inputs.empty? && active_tab&.shell && !active_tab.local_busy?
517
+ run_kwsh_loop(active_tab.shell, tab: active_tab, history: build_kwsh_history(active_tab.agent))
483
518
  end
484
519
  input = @pending_inputs.shift || (active_tab ? poll_active_tab_input : @prompt.ask("You>"))
485
520
  if input.is_a?(Hash) && input[:editor_prompt]
@@ -537,7 +572,10 @@ module Kward
537
572
  end
538
573
  end
539
574
  next if handled
540
- next if shell_command_input?(command_input) && handle_interactive_shell_command(command_input, agent)
575
+ if shell_command_input?(command_input)
576
+ shell_result = handle_interactive_shell_command(command_input, agent)
577
+ next if shell_result == true || shell_result == :tab_action
578
+ end
541
579
 
542
580
  flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
543
581
  flush_pending_reasoning_config(conversation: agent.conversation) unless active_tab
@@ -15,10 +15,17 @@ module Kward
15
15
 
16
16
  def reasoning_blocks(message)
17
17
  response_blocks = response_items(message).flat_map do |item|
18
- next [] unless MessageAccess.value(item, :type) == "reasoning"
19
-
20
- summary = response_item_text_parts(MessageAccess.value(item, :summary))
21
- summary.empty? ? response_item_text_parts(MessageAccess.value(item, :content)) : summary
18
+ case MessageAccess.value(item, :type)
19
+ when "reasoning"
20
+ summary = response_item_text_parts(MessageAccess.value(item, :summary))
21
+ summary.empty? ? response_item_text_parts(MessageAccess.value(item, :content)) : summary
22
+ when "message"
23
+ next [] unless MessageAccess.value(item, :phase).to_s == "commentary"
24
+
25
+ response_item_text_parts(MessageAccess.value(item, :content))
26
+ else
27
+ []
28
+ end
22
29
  end
23
30
  return response_blocks unless response_blocks.empty?
24
31
 
@@ -1,14 +1,14 @@
1
1
  require "digest"
2
2
  require "fileutils"
3
3
  require "json"
4
- require "yaml"
5
4
  require_relative "frontmatter"
6
5
  require_relative "deep_copy"
7
6
  require_relative "private_file"
8
7
  require_relative "path_guard"
9
8
  require_relative "permissions/policy"
10
9
  require_relative "sandbox/policy"
11
- require_relative "ekwsh"
10
+ require_relative "kwsh"
11
+ require_relative "kwshrc"
12
12
  require_relative "editor_mode"
13
13
  require_relative "diff_view_mode"
14
14
  require_relative "prompts/templates"
@@ -107,9 +107,14 @@ module Kward
107
107
  File.join(config_dir, "cache")
108
108
  end
109
109
 
110
- # @return [String] embedded-shell YAML config path
111
- def ekwsh_config_path
112
- File.join(config_dir, "ekwsh.yml")
110
+ # @return [String] embedded-shell rc config path
111
+ def kwshrc_path
112
+ File.join(config_dir, "kwshrc")
113
+ end
114
+
115
+ # @return [Array<String>] embedded-shell rc config paths in load order
116
+ def kwshrc_paths
117
+ [kwshrc_path, File.expand_path("~/.kwshrc")].uniq
113
118
  end
114
119
 
115
120
  def workspace_hooks_path(root = Dir.pwd)
@@ -137,6 +142,7 @@ module Kward
137
142
  "editor" => {
138
143
  "mode" => "modern",
139
144
  "agent" => {},
145
+ "runners" => {},
140
146
  "auto_indent" => true,
141
147
  "auto_close_pairs" => true,
142
148
  "soft_wrap" => true,
@@ -340,67 +346,25 @@ module Kward
340
346
  merged
341
347
  end
342
348
 
343
- def read_ekwsh_config(path = ekwsh_config_path)
344
- path = File.expand_path(path)
345
- return normalize_ekwsh_config(nil) unless File.exist?(path)
346
-
347
- data = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
348
- normalize_ekwsh_config(data)
349
- rescue Psych::SyntaxError => e
350
- raise "Invalid ekwsh YAML config: #{path}: #{e.message}"
351
- end
352
-
353
- def normalize_ekwsh_config(data)
354
- data = data.transform_keys(&:to_s) if data.is_a?(Hash)
355
- settings = data.is_a?(Hash) ? data : {}
356
- {
357
- shell: normalize_ekwsh_shell(settings["shell"]),
358
- timeout_seconds: normalize_positive_integer(settings["timeout_seconds"], Ekwsh::DEFAULT_TIMEOUT_SECONDS),
359
- max_output_bytes: normalize_positive_integer(settings["max_output_bytes"], Ekwsh::DEFAULT_MAX_OUTPUT_BYTES),
360
- history_limit: normalize_positive_integer(settings["history_limit"], Ekwsh::DEFAULT_HISTORY_LIMIT),
361
- env: normalize_ekwsh_env(settings["env"]),
362
- aliases: normalize_ekwsh_aliases(settings["aliases"])
349
+ def read_kwsh_config
350
+ config = {
351
+ shell: Kwsh::DEFAULT_SHELL,
352
+ timeout_seconds: Kwsh::DEFAULT_TIMEOUT_SECONDS,
353
+ max_output_bytes: Kwsh::DEFAULT_MAX_OUTPUT_BYTES,
354
+ history_limit: Kwsh::DEFAULT_HISTORY_LIMIT,
355
+ env: {},
356
+ aliases: {}
363
357
  }
358
+ rc_config = read_kwshrc_config
359
+ config[:env].merge!(rc_config[:env])
360
+ config[:aliases].merge!(rc_config[:aliases])
361
+ config
364
362
  end
365
363
 
366
- def normalize_ekwsh_shell(value)
367
- shell = value.to_s.strip
368
- return Ekwsh::DEFAULT_SHELL if shell.empty?
369
- return shell if shell.start_with?("/") && File.executable?(shell)
370
-
371
- Ekwsh::DEFAULT_SHELL
372
- end
373
-
374
- def normalize_positive_integer(value, default)
375
- integer = Integer(value)
376
- integer.positive? ? integer : default
377
- rescue ArgumentError, TypeError
378
- default
379
- end
380
-
381
- def normalize_ekwsh_env(values)
382
- return {} unless values.is_a?(Hash)
383
-
384
- values.each_with_object({}) do |(key, value), result|
385
- key = key.to_s
386
- next unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
387
- next if value.nil?
388
-
389
- result[key] = value.to_s
390
- end
391
- end
392
-
393
- def normalize_ekwsh_aliases(values)
394
- return {} unless values.is_a?(Hash)
395
-
396
- values.each_with_object({}) do |(name, command), result|
397
- name = name.to_s
398
- command = command.to_s.strip
399
- next unless Ekwsh.valid_alias_name?(name)
400
- next if command.empty?
401
-
402
- result[name] = command
403
- end
364
+ def read_kwshrc_config(paths = kwshrc_paths, env: ENV.to_h)
365
+ Kwshrc.read(paths, env: env)
366
+ rescue Kwshrc::ParseError => e
367
+ raise "Invalid kwshrc config: #{e.message}"
404
368
  end
405
369
 
406
370
  # Merges top-level config values and writes the updated config privately.
@@ -483,19 +447,69 @@ module Kward
483
447
  EditorMode.normalize(editor["mode"])
484
448
  end
485
449
 
450
+ # Returns configured editor runner definitions, or an empty map.
451
+ #
452
+ # Runner defaults are resolved by the editor runner at execution time so
453
+ # the generated config remains portable across machines.
454
+ def editor_runners(config = read_config)
455
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
456
+ runners = editor["runners"]
457
+ runners.is_a?(Hash) ? runners : {}
458
+ end
459
+
460
+ # Returns the optional provider override for editor-agent turns.
461
+ def editor_agent_provider(config = read_config)
462
+ value = ENV["KWARD_EDITOR_PROVIDER"].to_s.strip
463
+ return value unless value.empty?
464
+
465
+ editor_agent_setting(config, "provider")
466
+ end
467
+
486
468
  # Returns the optional model override for editor-agent turns.
487
469
  def editor_agent_model(config = read_config)
488
- editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
489
- agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
490
- value = agent["model"].to_s.strip
491
- value.empty? ? nil : value
470
+ editor_agent_setting(config, "model")
492
471
  end
493
472
 
494
473
  # Returns the optional reasoning-effort override for editor-agent turns.
495
474
  def editor_agent_reasoning_effort(config = read_config)
475
+ editor_agent_setting(config, "reasoning_effort")
476
+ end
477
+
478
+ # Returns the shell-agent provider override, preferring the environment.
479
+ def shell_agent_provider(config = read_config)
480
+ value = ENV["KWSH_PROVIDER"].to_s.strip
481
+ return value unless value.empty?
482
+
483
+ shell_agent_setting(config, "provider")
484
+ end
485
+
486
+ # Returns the shell-agent model override, preferring the environment.
487
+ def shell_agent_model(config = read_config)
488
+ value = ENV["KWSH_MODE"].to_s.strip
489
+ return value unless value.empty?
490
+
491
+ shell_agent_setting(config, "model")
492
+ end
493
+
494
+ # Returns the shell-agent reasoning-effort override, preferring the environment.
495
+ def shell_agent_reasoning_effort(config = read_config)
496
+ value = ENV["KWSH_REASONING"].to_s.strip
497
+ return value unless value.empty?
498
+
499
+ shell_agent_setting(config, "reasoning_effort")
500
+ end
501
+
502
+ def editor_agent_setting(config, key)
496
503
  editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
497
504
  agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
498
- value = agent["reasoning_effort"].to_s.strip
505
+ value = agent[key].to_s.strip
506
+ value.empty? ? nil : value
507
+ end
508
+
509
+ def shell_agent_setting(config, key)
510
+ shell = config["shell"].is_a?(Hash) ? config["shell"] : {}
511
+ agent = shell["agent"].is_a?(Hash) ? shell["agent"] : {}
512
+ value = agent[key].to_s.strip
499
513
  value.empty? ? nil : value
500
514
  end
501
515
 
@@ -0,0 +1,44 @@
1
+ require "thread"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Owns a command that continues after its terminal has been returned to Kward.
6
+ class DetachedRun
7
+ attr_reader :sink
8
+
9
+ def initialize(sink:, canceler: nil, &work)
10
+ @sink = sink
11
+ @canceler = canceler
12
+ @mutex = Mutex.new
13
+ @result = nil
14
+ @thread = Thread.new do
15
+ begin
16
+ result = work.call
17
+ @mutex.synchronize { @result = result }
18
+ rescue StandardError => e
19
+ @mutex.synchronize { @result = e }
20
+ end
21
+ end
22
+ @thread.report_on_exception = false
23
+ end
24
+
25
+ def complete?
26
+ !@thread.alive?
27
+ end
28
+
29
+ def result
30
+ return unless complete?
31
+
32
+ @thread.join
33
+ @mutex.synchronize { @result }
34
+ end
35
+
36
+ def join(timeout = nil)
37
+ @thread.join(timeout)
38
+ end
39
+
40
+ def cancel!
41
+ @canceler&.call
42
+ end
43
+ end
44
+ end