kward 0.82.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +1 -0
  9. data/doc/api.md +4 -0
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +82 -20
  12. data/doc/editor.md +47 -22
  13. data/doc/files.md +12 -7
  14. data/doc/getting-started.md +3 -0
  15. data/doc/pan.md +19 -15
  16. data/doc/permissions.md +1 -0
  17. data/doc/platform-support.md +48 -0
  18. data/doc/releasing.md +12 -4
  19. data/doc/rpc.md +2 -2
  20. data/doc/sandboxing.md +5 -1
  21. data/doc/security.md +4 -3
  22. data/doc/shell.md +101 -60
  23. data/doc/tabs.md +3 -0
  24. data/doc/troubleshooting.md +12 -2
  25. data/doc/usage.md +7 -6
  26. data/kward.gemspec +5 -4
  27. data/lib/kward/agent.rb +7 -3
  28. data/lib/kward/ansi.rb +110 -10
  29. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  30. data/lib/kward/cli/auth_commands.rb +34 -13
  31. data/lib/kward/cli/commands.rb +83 -62
  32. data/lib/kward/cli/doctor.rb +39 -17
  33. data/lib/kward/cli/hook_commands.rb +22 -12
  34. data/lib/kward/cli/interactive_turn.rb +144 -21
  35. data/lib/kward/cli/project_skills_commands.rb +8 -4
  36. data/lib/kward/cli/prompt_interface.rb +27 -0
  37. data/lib/kward/cli/rendering.rb +15 -9
  38. data/lib/kward/cli/runtime_helpers.rb +260 -55
  39. data/lib/kward/cli/slash_commands.rb +21 -12
  40. data/lib/kward/cli/tabs.rb +91 -13
  41. data/lib/kward/cli/tool_summaries.rb +14 -0
  42. data/lib/kward/cli.rb +55 -7
  43. data/lib/kward/cli_transcript_formatter.rb +11 -4
  44. data/lib/kward/compaction/token_estimator.rb +12 -6
  45. data/lib/kward/config_files.rb +94 -63
  46. data/lib/kward/detached_run.rb +44 -0
  47. data/lib/kward/editor_prompt.rb +46 -0
  48. data/lib/kward/editor_prompt_session.rb +28 -0
  49. data/lib/kward/interactive_pty_runner.rb +102 -28
  50. data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
  51. data/lib/kward/kwshrc.rb +233 -0
  52. data/lib/kward/markdown_code_block.rb +136 -0
  53. data/lib/kward/model/client.rb +35 -39
  54. data/lib/kward/model/model_info.rb +3 -2
  55. data/lib/kward/model/payloads.rb +0 -2
  56. data/lib/kward/model/provider_catalog.rb +5 -0
  57. data/lib/kward/model/stream_parser.rb +20 -4
  58. data/lib/kward/pan/index.html.erb +3 -3
  59. data/lib/kward/pan/server.rb +23 -3
  60. data/lib/kward/persistent_shell_session.rb +843 -0
  61. data/lib/kward/project_files.rb +19 -6
  62. data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
  63. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  64. data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
  65. data/lib/kward/prompt_interface/editor/controller.rb +99 -33
  66. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  67. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  68. data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
  69. data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
  70. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  71. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/state.rb +21 -10
  73. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  74. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  75. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  76. data/lib/kward/prompt_interface/file_overlay.rb +92 -22
  77. data/lib/kward/prompt_interface/key_handler.rb +75 -0
  78. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  79. data/lib/kward/prompt_interface/project_browser.rb +443 -17
  80. data/lib/kward/prompt_interface/runtime_state.rb +55 -2
  81. data/lib/kward/prompt_interface/screen.rb +2 -2
  82. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  83. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  84. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  85. data/lib/kward/prompt_interface.rb +169 -18
  86. data/lib/kward/prompts/commands.rb +2 -1
  87. data/lib/kward/prompts.rb +1 -1
  88. data/lib/kward/pty_output_sink.rb +47 -0
  89. data/lib/kward/rpc/auth_manager.rb +1 -1
  90. data/lib/kward/rpc/server.rb +2 -1
  91. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  92. data/lib/kward/scratchpad_languages.rb +74 -0
  93. data/lib/kward/scratchpad_runner.rb +155 -29
  94. data/lib/kward/shell_prompt.rb +52 -0
  95. data/lib/kward/shell_prompt_session.rb +58 -0
  96. data/lib/kward/terminal_keys.rb +13 -0
  97. data/lib/kward/terminal_text.rb +121 -0
  98. data/lib/kward/text_matcher.rb +18 -0
  99. data/lib/kward/tools/open_editor.rb +41 -0
  100. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  101. data/lib/kward/tools/registry.rb +75 -8
  102. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  103. data/lib/kward/tools/run_shell_command.rb +24 -6
  104. data/lib/kward/tools/tool_call.rb +2 -1
  105. data/lib/kward/version.rb +1 -1
  106. data/templates/default/fulldoc/html/css/kward.css +0 -125
  107. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  108. data/templates/default/fulldoc/html/setup.rb +1 -1
  109. data/templates/default/kward_navigation.rb +1 -0
  110. data/templates/default/layout/html/footer.erb +10 -0
  111. data/templates/default/layout/html/headers.erb +23 -0
  112. data/templates/default/layout/html/layout.erb +6 -18
  113. data/templates/default/layout/html/setup.rb +41 -2
  114. metadata +44 -8
  115. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
@@ -33,8 +33,11 @@ module Kward
33
33
  :markdown_chunks,
34
34
  :label,
35
35
  :unread,
36
+ :attention,
36
37
  :pending_question,
37
38
  :shell,
39
+ :shell_agent,
40
+ :background_run,
38
41
  :transient_shell_entries,
39
42
  :error_reported,
40
43
  :local_busy_activity,
@@ -105,6 +108,10 @@ module Kward
105
108
  @cli.send(:ask_tab_user_question, @tab, questions, cancellation: cancellation)
106
109
  end
107
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
+
108
115
  def ask_tool_approval(tool_name:, args:, reason: nil)
109
116
  answers = ask_user_question([
110
117
  {
@@ -315,8 +322,11 @@ module Kward
315
322
  markdown_chunks: [],
316
323
  label: label,
317
324
  unread: false,
325
+ attention: nil,
318
326
  pending_question: nil,
319
327
  shell: nil,
328
+ shell_agent: nil,
329
+ background_run: nil,
320
330
  transient_shell_entries: [],
321
331
  error_reported: false,
322
332
  local_busy_activity: nil
@@ -418,12 +428,14 @@ module Kward
418
428
  end
419
429
 
420
430
  if @tabs.length <= 1
431
+ close_kwsh_session(tab.shell) if tab&.shell && respond_to?(:close_kwsh_session, true)
421
432
  @tabs.clear
422
433
  persist_tabs
423
434
  return PromptInterface::EXIT_INPUT
424
435
  end
425
436
 
426
437
  stop_tab_live_view
438
+ close_kwsh_session(tab.shell) if tab&.shell && respond_to?(:close_kwsh_session, true)
427
439
  tab.session&.delete_if_unused if tab&.session.respond_to?(:delete_if_unused)
428
440
  tab.driver.close if tab.driver.respond_to?(:close)
429
441
  @tabs.delete_at(@active_tab_index)
@@ -457,12 +469,15 @@ module Kward
457
469
  tab.error = nil
458
470
  tab.answer = nil
459
471
  tab.unread = false
472
+ tab.attention = nil
460
473
  tab.error_reported = false
461
474
  tab.event_history.clear
462
475
  tab.seen_events = 0
463
476
  tab.queued_inputs.clear
464
477
  tab.steering = nil
478
+ close_kwsh_session(tab.shell) if tab.shell && respond_to?(:close_kwsh_session, true)
465
479
  tab.shell = nil
480
+ tab.shell_agent = nil
466
481
  tab.clear_transient_shell_entries
467
482
  tab.stream_state = new_tab_stream_state(tab.driver)
468
483
  tab.markdown_chunks.clear
@@ -495,6 +510,7 @@ module Kward
495
510
  end
496
511
 
497
512
  def activate_tab(index, render: true)
513
+ service_background_tab_runs
498
514
  tab = @tabs[index]
499
515
  return nil unless tab
500
516
 
@@ -511,6 +527,7 @@ module Kward
511
527
  @prompt.update_assistant_label(assistant_prompt_name) if @prompt.respond_to?(:update_assistant_label)
512
528
  end
513
529
  tab.unread = false
530
+ tab.attention = nil
514
531
  restore_tab_composer_snapshot(tab.snapshot)
515
532
  update_prompt_workspace_root(current_workspace_root)
516
533
  @prompt.refresh_composer_status if @prompt.respond_to?(:refresh_composer_status)
@@ -597,6 +614,44 @@ module Kward
597
614
  index ? index + 1 : active_tab_number
598
615
  end
599
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
+
600
655
  def submit_tab_input(tab, input, display_input: nil)
601
656
  return if input.to_s.strip.empty?
602
657
 
@@ -610,6 +665,7 @@ module Kward
610
665
  print_user_transcript(input, display_input: display_input) if prompt_interface?
611
666
  tab.status = "queued"
612
667
  tab.unread = false
668
+ tab.attention = nil
613
669
  tab.cancellation = Cancellation.new
614
670
  tab.steering = tab.driver.supports_steering? && steering_supported? ? Steering.new : nil
615
671
  tab.error = nil
@@ -633,14 +689,17 @@ module Kward
633
689
  end
634
690
  tab.status = "ready"
635
691
  tab.unread = tab != active_tab
692
+ tab.attention = :output if tab != active_tab
636
693
  rescue Cancellation::CancelledError
637
694
  tab.status = "cancelled"
638
695
  tab.unread = false
696
+ tab.attention = :failure if tab != active_tab
639
697
  report_tab_runtime_error(tab)
640
698
  rescue StandardError => e
641
699
  tab.error = e.message
642
700
  tab.status = "failed"
643
701
  tab.unread = false
702
+ tab.attention = :failure if tab != active_tab
644
703
  report_tab_runtime_error(tab)
645
704
  ensure
646
705
  finish_tab_turn(tab)
@@ -656,8 +715,9 @@ module Kward
656
715
  end
657
716
 
658
717
  def poll_active_tab_input
718
+ service_background_tab_runs
659
719
  tab = active_tab
660
- unless tab&.running?
720
+ unless tab&.running? || tab&.local_busy?
661
721
  input = @prompt.ask("You>")
662
722
  return { tab_action: :close } if input.nil? && tab && @tabs.length > 1
663
723
 
@@ -666,6 +726,7 @@ module Kward
666
726
 
667
727
  @prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
668
728
  loop do
729
+ service_background_tab_runs
669
730
  refresh_active_tab
670
731
  if service_active_tab_question
671
732
  return next_tab_queued_input(tab) if tab.idle? && !tab.queued_inputs.empty?
@@ -673,6 +734,10 @@ module Kward
673
734
 
674
735
  next
675
736
  end
737
+ if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
738
+ sleep 0.01
739
+ next
740
+ end
676
741
  poll_result = @prompt.poll_input
677
742
  case poll_result
678
743
  when Hash
@@ -681,8 +746,12 @@ module Kward
681
746
  return poll_result
682
747
  end
683
748
  when PromptInterface::CANCEL_INPUT
684
- tab.cancellation&.cancel!
685
- 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
686
755
  when PromptInterface::EXIT_INPUT
687
756
  tab.queued_inputs << "/exit"
688
757
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
@@ -824,15 +893,17 @@ module Kward
824
893
  return if tab.stream_state[:streamed]
825
894
  return if tab.answer.to_s.empty?
826
895
 
827
- @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")
828
897
  end
829
898
 
830
899
  def new_tab_stream_state(driver)
831
900
  {
832
901
  streamed: false,
902
+ assistant_arrived: false,
833
903
  last_flush: monotonic_now,
834
904
  stream_block_open: false,
835
905
  markdown_streams: {},
906
+ tool_elapsed_ms: {},
836
907
  defer_assistant_streaming: driver.respond_to?(:conversation) && defer_assistant_streaming?(driver.agent)
837
908
  }
838
909
  end
@@ -847,10 +918,11 @@ module Kward
847
918
  end
848
919
 
849
920
  def tab_label_color(tab)
850
- return :green if tab.status.to_s == "waiting_for_question"
851
- return :yellow if tab.running? || tab.local_busy?
852
- return :red if %w[failed cancelled].include?(tab.status.to_s)
853
- 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
854
926
 
855
927
  nil
856
928
  end
@@ -959,11 +1031,17 @@ module Kward
959
1031
  def stop_tabs
960
1032
  stop_tab_live_view
961
1033
  Array(@tabs).each do |tab|
962
- next unless tab&.running?
963
-
964
- tab.cancellation&.cancel!
965
- tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
966
- tab.thread&.join(0.2)
1034
+ if tab&.running?
1035
+ tab.cancellation&.cancel!
1036
+ tab.thread&.raise(Cancellation::CancelledError, "cancelled") if tab.thread&.alive?
1037
+ tab.thread&.join(0.2)
1038
+ end
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)
967
1045
  rescue StandardError
968
1046
  nil
969
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
@@ -10,11 +10,16 @@ require_relative "compactor"
10
10
  require_relative "config_files"
11
11
  require_relative "clipboard"
12
12
  require_relative "cancellation"
13
+ require_relative "editor_prompt"
14
+ require_relative "editor_prompt_session"
15
+ require_relative "shell_prompt"
16
+ require_relative "shell_prompt_session"
13
17
  require_relative "cli_transcript_formatter"
14
18
  require_relative "model/context_usage"
15
19
  require_relative "events"
16
20
  require_relative "export_path"
17
- require_relative "ekwsh"
21
+ require_relative "kwsh"
22
+ require_relative "persistent_shell_session"
18
23
  require_relative "interactive_pty_runner"
19
24
  require_relative "adaptive_pty_output_sink"
20
25
  require_relative "pty_transcript_normalizer"
@@ -87,7 +92,7 @@ module Kward
87
92
  RESTORED_TOOL_OUTPUT_LIMIT = 2_000
88
93
  INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT = 10
89
94
  STREAM_RENDER_INTERVAL = 0.025
90
- INTERACTIVE_EVENT_DRAIN_LIMIT = 100
95
+ INTERACTIVE_EVENT_DRAIN_LIMIT = 12
91
96
  BUILTIN_SLASH_COMMANDS = PromptCommands::BUILTIN_COMMANDS
92
97
  BUILTIN_SLASH_COMMAND_NAMES = PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES
93
98
 
@@ -151,6 +156,16 @@ module Kward
151
156
  warn e.message
152
157
  warn "Run `kward help` for available commands."
153
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
154
169
  ensure
155
170
  ConfigFiles.skip_config = false
156
171
  end
@@ -179,6 +194,30 @@ module Kward
179
194
  MESSAGE
180
195
  end
181
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
+
182
221
  def dispatch
183
222
  if @prompt_delimited
184
223
  ConfigFiles.ensure_default_config!
@@ -224,7 +263,7 @@ module Kward
224
263
  raise ArgumentError, command_usage("doctor") unless @argv.length == 1
225
264
 
226
265
  ensure_client!
227
- print_doctor
266
+ exit 1 unless print_doctor
228
267
  return
229
268
  end
230
269
 
@@ -234,7 +273,7 @@ module Kward
234
273
  return
235
274
  end
236
275
 
237
- handle_hooks_command((@argv[1..] || []).join(" "))
276
+ handle_hooks_command((@argv[1..] || []).join(" "), command_line: true)
238
277
  return
239
278
  end
240
279
 
@@ -473,10 +512,16 @@ module Kward
473
512
  install_bang_completion_provider(agent)
474
513
 
475
514
  loop do
476
- if @pending_inputs.empty? && active_tab&.shell
477
- 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))
478
518
  end
479
519
  input = @pending_inputs.shift || (active_tab ? poll_active_tab_input : @prompt.ask("You>"))
520
+ if input.is_a?(Hash) && input[:editor_prompt]
521
+ pending_inputs = run_editor_prompt_turn(input[:editor_prompt], active_tab&.agent || agent)
522
+ pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
523
+ next
524
+ end
480
525
  if input.is_a?(Hash) && input[:tab_action]
481
526
  tab_result = handle_tab_action(input, session_store)
482
527
  break if tab_result == PromptInterface::EXIT_INPUT
@@ -527,7 +572,10 @@ module Kward
527
572
  end
528
573
  end
529
574
  next if handled
530
- 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
531
579
 
532
580
  flush_pending_reasoning_config(conversation: active_tab.agent.conversation) if active_tab&.agent
533
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
 
@@ -31,13 +31,15 @@ module Kward
31
31
  parts = [role]
32
32
  if role.to_s == "compactionSummary"
33
33
  parts << value(message, :summary)
34
- else
34
+ elsif response_items(message).empty?
35
35
  parts << content_text(value(message, :content))
36
- end
37
- parts << value(message, :reasoning_summary)
38
- tool_calls(message).each do |tool_call|
39
- parts << tool_call_name(tool_call)
40
- parts << tool_call_arguments(tool_call)
36
+ parts << value(message, :reasoning_summary)
37
+ tool_calls(message).each do |tool_call|
38
+ parts << tool_call_name(tool_call)
39
+ parts << tool_call_arguments(tool_call)
40
+ end
41
+ else
42
+ parts << JSON.generate(response_items(message))
41
43
  end
42
44
  parts << value(message, :tool_call_id)
43
45
  parts << value(message, :name)
@@ -65,6 +67,10 @@ module Kward
65
67
  MessageAccess.tool_calls(message)
66
68
  end
67
69
 
70
+ def response_items(message)
71
+ MessageAccess.response_items(message)
72
+ end
73
+
68
74
  def tool_call_name(tool_call)
69
75
  ToolCall.name(tool_call)
70
76
  end
@@ -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)
@@ -136,6 +141,8 @@ module Kward
136
141
  },
137
142
  "editor" => {
138
143
  "mode" => "modern",
144
+ "agent" => {},
145
+ "runners" => {},
139
146
  "auto_indent" => true,
140
147
  "auto_close_pairs" => true,
141
148
  "soft_wrap" => true,
@@ -339,67 +346,25 @@ module Kward
339
346
  merged
340
347
  end
341
348
 
342
- def read_ekwsh_config(path = ekwsh_config_path)
343
- path = File.expand_path(path)
344
- return normalize_ekwsh_config(nil) unless File.exist?(path)
345
-
346
- data = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
347
- normalize_ekwsh_config(data)
348
- rescue Psych::SyntaxError => e
349
- raise "Invalid ekwsh YAML config: #{path}: #{e.message}"
350
- end
351
-
352
- def normalize_ekwsh_config(data)
353
- data = data.transform_keys(&:to_s) if data.is_a?(Hash)
354
- settings = data.is_a?(Hash) ? data : {}
355
- {
356
- shell: normalize_ekwsh_shell(settings["shell"]),
357
- timeout_seconds: normalize_positive_integer(settings["timeout_seconds"], Ekwsh::DEFAULT_TIMEOUT_SECONDS),
358
- max_output_bytes: normalize_positive_integer(settings["max_output_bytes"], Ekwsh::DEFAULT_MAX_OUTPUT_BYTES),
359
- history_limit: normalize_positive_integer(settings["history_limit"], Ekwsh::DEFAULT_HISTORY_LIMIT),
360
- env: normalize_ekwsh_env(settings["env"]),
361
- 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: {}
362
357
  }
358
+ rc_config = read_kwshrc_config
359
+ config[:env].merge!(rc_config[:env])
360
+ config[:aliases].merge!(rc_config[:aliases])
361
+ config
363
362
  end
364
363
 
365
- def normalize_ekwsh_shell(value)
366
- shell = value.to_s.strip
367
- return Ekwsh::DEFAULT_SHELL if shell.empty?
368
- return shell if shell.start_with?("/") && File.executable?(shell)
369
-
370
- Ekwsh::DEFAULT_SHELL
371
- end
372
-
373
- def normalize_positive_integer(value, default)
374
- integer = Integer(value)
375
- integer.positive? ? integer : default
376
- rescue ArgumentError, TypeError
377
- default
378
- end
379
-
380
- def normalize_ekwsh_env(values)
381
- return {} unless values.is_a?(Hash)
382
-
383
- values.each_with_object({}) do |(key, value), result|
384
- key = key.to_s
385
- next unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
386
- next if value.nil?
387
-
388
- result[key] = value.to_s
389
- end
390
- end
391
-
392
- def normalize_ekwsh_aliases(values)
393
- return {} unless values.is_a?(Hash)
394
-
395
- values.each_with_object({}) do |(name, command), result|
396
- name = name.to_s
397
- command = command.to_s.strip
398
- next unless Ekwsh.valid_alias_name?(name)
399
- next if command.empty?
400
-
401
- result[name] = command
402
- 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}"
403
368
  end
404
369
 
405
370
  # Merges top-level config values and writes the updated config privately.
@@ -482,6 +447,72 @@ module Kward
482
447
  EditorMode.normalize(editor["mode"])
483
448
  end
484
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
+
468
+ # Returns the optional model override for editor-agent turns.
469
+ def editor_agent_model(config = read_config)
470
+ editor_agent_setting(config, "model")
471
+ end
472
+
473
+ # Returns the optional reasoning-effort override for editor-agent turns.
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)
503
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
504
+ agent = editor["agent"].is_a?(Hash) ? editor["agent"] : {}
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
513
+ value.empty? ? nil : value
514
+ end
515
+
485
516
  # Returns whether the built-in TUI editor should auto-indent new lines.
486
517
  def editor_auto_indent?(config = read_config)
487
518
  editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
@@ -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