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
@@ -11,7 +11,7 @@ module Kward
11
11
  prepare_transcript_output_locked unless @restoring_transcript
12
12
  if label && @stream_state.block != label
13
13
  ensure_transcript_block_separator_locked
14
- write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))} ")
14
+ write_transcript_text_locked("#{colored(transcript_marker(label), *label_styles(label))} ")
15
15
  @stream_state.start_block(label)
16
16
  end
17
17
  write_transcript_text_locked(delta) unless delta.empty?
@@ -117,20 +117,25 @@ module Kward
117
117
  end
118
118
  end
119
119
 
120
+ def transcript_marker(label)
121
+ marker = "#{transcript_label(label)}>"
122
+ label == "Assistant" ? "✦ #{marker}" : marker
123
+ end
124
+
120
125
  def label_styles(label)
121
126
  case label
122
127
  when "Reasoning", "Compaction summary"
123
- [:gray, :bold]
128
+ [:metadata, :bold]
124
129
  when "Assistant", "Kward"
125
- [:green, :bold]
130
+ [:activity, :bold]
126
131
  when "Tool", "Tool output"
127
- [:cyan, :bold]
132
+ [:tool, :bold]
128
133
  when "Tool failed"
129
- [:red, :bold]
134
+ [:failure, :bold]
130
135
  when "Retry"
131
- [:yellow, :bold]
136
+ [:caution, :bold]
132
137
  else
133
- [:gray, :bold]
138
+ [:metadata, :bold]
134
139
  end
135
140
  end
136
141
 
@@ -15,6 +15,8 @@ require_relative "terminal_sequences"
15
15
  require_relative "terminal_keys"
16
16
  require_relative "editor_mode"
17
17
  require_relative "diff_view_mode"
18
+ require_relative "scratchpad_languages"
19
+ require_relative "markdown_code_block"
18
20
  require_relative "prompt_interface/banner"
19
21
  require_relative "prompt_interface/composer_state"
20
22
  require_relative "prompt_interface/editor/state"
@@ -32,6 +34,7 @@ require_relative "prompt_interface/git_prompt"
32
34
  require_relative "prompt_interface/overlay_renderer"
33
35
  require_relative "prompt_interface/editor/renderer"
34
36
  require_relative "prompt_interface/editor/syntax_highlighter"
37
+ require_relative "prompt_interface/editor/runner"
35
38
  require_relative "prompt_interface/editor/auto_close_pairs"
36
39
  require_relative "prompt_interface/editor/endwise"
37
40
  require_relative "prompt_interface/editor/word_completion"
@@ -64,12 +67,15 @@ module Kward
64
67
  # `prompt_interface/` to keep rendering, layout, keyboard handling, and runtime
65
68
  # state readable while sharing one terminal state object.
66
69
  class PromptInterface
67
- HELP_TEXT = "Enter sends • Shift+Enter inserts newline • ↑/↓ history • Ctrl+D exits empty prompt".freeze
70
+ HELP_TEXT = "Enter sends • Shift+Enter newline • / commands • @ files • ↑/↓ history • Ctrl+D exits".freeze
68
71
  BUSY_HELP_TEXT = "Ctrl+C cancels".freeze
69
- SPINNER_FRAMES = %w[ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
70
- SPINNER_INTERVAL = 0.1
72
+ SPINNER_FRAMES = %w[· ].freeze
73
+ SPINNER_INTERVAL = 0.16
71
74
  FOOTER_REFRESH_INTERVAL = 1.0
72
75
  COMPOSER_STATUS_REFRESH_INTERVAL = 1.0
76
+ COMPLETION_DISPLAY_SECONDS = 0.8
77
+ RESPONSE_ARRIVAL_SECONDS = 0.4
78
+ BUSY_ELAPSED_DELAY_SECONDS = 2.0
73
79
  COMPOSER_MAX_INPUT_ROWS = 6
74
80
  IMAGE_VIEWER_MAX_ROWS = 8
75
81
  TRANSCRIPT_BUFFER_LIMIT = 200_000
@@ -89,6 +95,7 @@ module Kward
89
95
  include EditorEndwise
90
96
  include EditorWordCompletion
91
97
  include EditorAutoIndent
98
+ include EditorRunner
92
99
  include ComposerRenderer
93
100
  include ComposerController
94
101
  include ModernEditorMode
@@ -133,7 +140,7 @@ module Kward
133
140
  end
134
141
  end
135
142
 
136
- def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, workspace_root: Dir.pwd, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil, redraw_handler: nil)
143
+ def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, workspace_root: Dir.pwd, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil, editor_runners_source: nil, redraw_handler: nil)
137
144
  @input_io = input
138
145
  @output_io = output
139
146
  # Logical state may change during a child handoff, but only the owner may render.
@@ -145,12 +152,14 @@ module Kward
145
152
  @workspace_root = File.expand_path(workspace_root.to_s.empty? ? Dir.pwd : workspace_root)
146
153
  @composer = ComposerState.new
147
154
  load_history(@prompt_history.values) if @prompt_history
148
- self.composer_input = @composer.input
149
- self.composer_cursor = @composer.cursor
150
155
  @started = false
151
156
  @asking = false
152
157
  @busy = false
153
- @busy_activity = "streaming"
158
+ @busy_activity = "thinking"
159
+ @busy_started_at = nil
160
+ @last_busy_elapsed = nil
161
+ @completion_status = nil
162
+ @response_arrival_until = nil
154
163
  @queued_count = 0
155
164
  @steered_count = 0
156
165
  @spinner_frame_index = 0
@@ -169,6 +178,7 @@ module Kward
169
178
  @transcript_viewport_rows = 0
170
179
  @restoring_transcript = false
171
180
  @pending_keys = []
181
+ @handoff_input_buffer = +"".b
172
182
  @completion_provider = nil
173
183
  @completion_cycle = nil
174
184
  @completion_overlay = nil
@@ -185,12 +195,22 @@ module Kward
185
195
  @file_open_dismissed_token = nil
186
196
  @file_editor_open_status = nil
187
197
  @file_mention_paths = nil
198
+ @file_mention_discovery_thread = nil
199
+ @file_mention_discovery_root = nil
200
+ @file_mention_discovery_token = nil
188
201
  @project_browser_file_paths = nil
189
202
  @project_browser_state = nil
190
203
  @image_viewer_state = nil
191
204
  @terminal_image_protocol = nil
192
205
  @project_browser_restore_after_editor = false
193
206
  @editor_state = nil
207
+ @editor_runner_state = nil
208
+ @editor_runner_completion = nil
209
+ @editor_runner_output_visible = false
210
+ @editor_runner_selection_anchor = nil
211
+ @editor_runner_selection_cursor = nil
212
+ @editor_runner_mouse_dragging = false
213
+ @editor_runner_last_click = nil
194
214
  @editor_agent_suspended = false
195
215
  @editor_prompt_active = false
196
216
  @editor_prompt_input = ""
@@ -232,6 +252,7 @@ module Kward
232
252
  @editor_line_numbers_source = editor_line_numbers_source
233
253
  @diff_view = DiffViewMode.normalize(diff_view)
234
254
  @diff_view_source = diff_view_source
255
+ @editor_runners_source = editor_runners_source
235
256
  end
236
257
 
237
258
  def update_slash_commands(commands)
@@ -439,6 +460,12 @@ module Kward
439
460
  return false unless opened
440
461
 
441
462
  run_editor
463
+ rescue StandardError
464
+ @mutex.synchronize do
465
+ close_editor if editor_active?
466
+ render_prompt_locked if @started
467
+ end
468
+ raise
442
469
  end
443
470
 
444
471
  def scratchpad(language = :text)
@@ -524,7 +551,8 @@ module Kward
524
551
  if key.nil?
525
552
  resized = handle_resize_locked
526
553
  footer_refreshed = tick_footer_locked
527
- render_prompt_locked if resized || footer_refreshed
554
+ completion_changed = tick_completion_locked
555
+ render_prompt_locked if resized || footer_refreshed || completion_changed
528
556
  else
529
557
  result = handle_key(key)
530
558
  render_prompt_locked unless result.is_a?(String) || result == EXIT_INPUT || prompt_action_result?(result)
@@ -667,11 +695,11 @@ module Kward
667
695
  end
668
696
  end
669
697
 
670
- def with_inline_terminal_handoff(&block)
671
- with_terminal_handoff(inline: true, &block)
698
+ def with_inline_terminal_handoff(preserve_tab_keybindings: false, &block)
699
+ with_terminal_handoff(inline: true, preserve_tab_keybindings: preserve_tab_keybindings, &block)
672
700
  end
673
701
 
674
- def with_terminal_handoff(inline: false)
702
+ def with_terminal_handoff(inline: false, preserve_tab_keybindings: false)
675
703
  start
676
704
  input = nil
677
705
  output = nil
@@ -687,7 +715,8 @@ module Kward
687
715
  end
688
716
  disable_editor_mouse_reporting(force: true)
689
717
  print_output_locked(BRACKETED_PASTE_RESTORE)
690
- print_output_locked(KEYBOARD_PROTOCOL_RESTORE)
718
+ print_output_locked(KEYBOARD_PROTOCOL_RESTORE) unless preserve_tab_keybindings
719
+ @handoff_input_buffer.clear
691
720
  restore_editor_cursor_shape_locked
692
721
  set_cursor_visible_locked(true, force: true)
693
722
  flush_output_locked
@@ -709,6 +738,7 @@ module Kward
709
738
  enter_raw_mode_locked
710
739
  print_output_locked(KEYBOARD_PROTOCOL_ENABLE)
711
740
  print_output_locked(BRACKETED_PASTE_ENABLE)
741
+ @handoff_input_buffer.clear
712
742
  disable_editor_mouse_reporting(force: true)
713
743
  restore_editor_cursor_shape_locked(force: true)
714
744
  @cursor_visible = nil
@@ -802,8 +832,6 @@ module Kward
802
832
  @completion_cycle = nil
803
833
  @completion_overlay = nil
804
834
  @prompt_label = snapshot[:prompt_label].to_s.empty? ? "You>" : snapshot[:prompt_label].to_s
805
- self.composer_input = @composer.input
806
- self.composer_cursor = @composer.cursor
807
835
  @last_composer_rows = []
808
836
  end
809
837
 
@@ -815,6 +843,11 @@ module Kward
815
843
 
816
844
  def restore_editor_snapshot_locked(snapshot)
817
845
  editor_was_active = editor_active?
846
+ cancel_editor_runner
847
+ @editor_runner_state = nil
848
+ @editor_runner_completion = nil
849
+ @editor_runner_output_visible = false
850
+ clear_editor_runner_selection
818
851
  @editor_state = snapshot[:editor_state]&.dup
819
852
  editor_is_active = editor_active?
820
853
 
@@ -839,17 +872,21 @@ module Kward
839
872
  end
840
873
  end
841
874
 
842
- def begin_busy_input(message = "You>", activity: "streaming")
875
+ def begin_busy_input(message = "You>", activity: "thinking")
843
876
  start
844
877
  @mutex.synchronize do
845
878
  @prompt_label = message.to_s
846
879
  @busy_activity = normalize_busy_activity(activity)
880
+ @completion_status = nil
881
+ @response_arrival_until = nil
847
882
  self.composer_input = ""
848
883
  self.composer_cursor = 0
849
884
  @composer.clear_attachments
850
885
  @pending_keys.clear
851
886
  @asking = true
852
887
  @busy = true
888
+ @busy_started_at = monotonic_now
889
+ @last_busy_elapsed = nil
853
890
  @queued_count = 0
854
891
  @steered_count = 0
855
892
  reset_spinner_locked
@@ -858,6 +895,17 @@ module Kward
858
895
  end
859
896
  end
860
897
 
898
+ def update_busy_activity(activity)
899
+ @mutex.synchronize do
900
+ value = normalize_busy_activity(activity)
901
+ return false if value == @busy_activity
902
+
903
+ @busy_activity = value
904
+ render_prompt_locked if @started && @asking && @busy
905
+ true
906
+ end
907
+ end
908
+
861
909
  def set_queued_count(count)
862
910
  @mutex.synchronize do
863
911
  @queued_count = count.to_i
@@ -877,15 +925,18 @@ module Kward
877
925
  def clear_steered_count
878
926
  @mutex.synchronize do
879
927
  @steered_count = 0
880
- @busy_activity = "streaming"
928
+ @busy_activity = "thinking"
881
929
  render_prompt_locked if @asking
882
930
  end
883
931
  end
884
932
 
885
933
  def finish_busy_input
886
934
  @mutex.synchronize do
935
+ @last_busy_elapsed = monotonic_now - @busy_started_at if @busy_started_at
936
+ @busy_started_at = nil
887
937
  @busy = false
888
- @busy_activity = "streaming"
938
+ @busy_activity = "thinking"
939
+ @response_arrival_until = nil
889
940
  @queued_count = 0
890
941
  @steered_count = 0
891
942
  @asking = true
@@ -893,6 +944,24 @@ module Kward
893
944
  end
894
945
  end
895
946
 
947
+ def show_response_arrival
948
+ @mutex.synchronize do
949
+ @response_arrival_until = monotonic_now + RESPONSE_ARRIVAL_SECONDS
950
+ render_prompt_locked if @started && @asking && @busy
951
+ end
952
+ end
953
+
954
+ def show_completion(status)
955
+ @mutex.synchronize do
956
+ @completion_status = completion_status(status)
957
+ render_prompt_locked if @started && @asking
958
+ end
959
+ end
960
+
961
+ def filter_terminal_handoff_input(chunk)
962
+ @mutex.synchronize { filter_terminal_handoff_input_locked(chunk) }
963
+ end
964
+
896
965
  def poll_input
897
966
  key = read_key(nonblock: true)
898
967
  @mutex.synchronize do
@@ -916,7 +985,8 @@ module Kward
916
985
  resized = handle_resize_locked
917
986
  spun = tick_spinner_locked
918
987
  footer_refreshed = tick_footer_locked
919
- render_prompt_locked if resized || spun || footer_refreshed
988
+ completion_changed = tick_completion_locked
989
+ render_prompt_locked if resized || spun || footer_refreshed || completion_changed
920
990
  return nil
921
991
  end
922
992
 
@@ -1106,6 +1176,11 @@ module Kward
1106
1176
 
1107
1177
  def reset_workspace_file_state_locked
1108
1178
  @file_mention_paths = nil
1179
+ @file_mention_discovery_thread = nil
1180
+ @file_mention_discovery_root = nil
1181
+ @file_mention_discovery_token = nil
1182
+ @file_overlay_match_cache = nil
1183
+ @project_browser_search_cache = nil
1109
1184
  @project_browser_file_paths = nil
1110
1185
  @file_mention_path_entries_paths = nil
1111
1186
  @file_mention_path_entries = nil
@@ -29,7 +29,7 @@ module Kward
29
29
  { name: "files", description: "Browse project files.", argument_hint: "" },
30
30
  { name: "shell", description: "Open the embedded Kward shell.", argument_hint: "" },
31
31
  { name: "capture", description: "Run a command with bounded transcript capture.", argument_hint: "<command>" },
32
- { name: "scratchpad", description: "Open an unsaved editor buffer.", argument_hint: "[text|markdown|ruby]" },
32
+ { name: "scratchpad", description: "Open an unsaved editor buffer.", argument_hint: "[language|help]" },
33
33
  { name: "pty", description: "Run a command in an interactive PTY passthrough session.", argument_hint: "<command>" },
34
34
  { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name|worktree]" },
35
35
  { name: "worktree", description: "Manage the active tab's linked Git worktree.", argument_hint: "[activate|detach|status|merge|merge abort|remove]" },
data/lib/kward/prompts.rb CHANGED
@@ -60,7 +60,7 @@ module Kward
60
60
 
61
61
  When web tools are available, use web_search to discover sources, fetch_content for important human-readable pages, and fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources and cite or mention the URLs you relied on. Use code_search for package, GitHub repository, and source-code research.
62
62
 
63
- Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. If output is compacted or replaced with a duplicate reference, use retrieve_tool_output to inspect the needed original detail. Use context_budget_stats when asked about context savings. When a material requirement is ambiguous, use ask_user_question when it is available; otherwise state the assumption.
63
+ Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. If output is compacted or replaced with a duplicate reference, use retrieve_tool_output to inspect the needed original detail. Use context_budget_stats when asked about context savings. When the user asks to open a file in the built-in editor and open_editor is advertised, use open_editor rather than read_file; use read_file when they ask you to inspect or display contents. Opening the editor is a user-facing action and does not itself modify or save the file. When a material requirement is ambiguous, use ask_user_question when it is available; otherwise state the assumption.
64
64
  PROMPT
65
65
  end
66
66
 
@@ -1,5 +1,52 @@
1
+ require "thread"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
5
+ # Retains output while a detached PTY command runs without terminal ownership.
6
+ class BufferedPtyOutputSink
7
+ attr_reader :captured_output
8
+
9
+ def initialize(max_capture_bytes:)
10
+ @max_capture_bytes = max_capture_bytes
11
+ @captured_output = +"".b
12
+ @truncated = false
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def write(chunk)
17
+ value = chunk.to_s.b
18
+ @mutex.synchronize do
19
+ remaining = @max_capture_bytes - @captured_output.bytesize
20
+ if value.bytesize > remaining
21
+ @captured_output << value.byteslice(0, remaining) if remaining.positive?
22
+ @truncated = true
23
+ else
24
+ @captured_output << value
25
+ end
26
+ end
27
+ end
28
+
29
+ def flush
30
+ nil
31
+ end
32
+
33
+ def finish
34
+ nil
35
+ end
36
+
37
+ def transcript_safe?
38
+ true
39
+ end
40
+
41
+ def pre_input_capture_only?
42
+ true
43
+ end
44
+
45
+ def truncated?
46
+ @mutex.synchronize { @truncated }
47
+ end
48
+ end
49
+
3
50
  # Forwards PTY output immediately and optionally keeps a bounded byte copy.
4
51
  #
5
52
  # The runner only depends on the sink's `write` method and optional `flush`
@@ -173,10 +173,14 @@ module Kward
173
173
  when "reasoning"
174
174
  reasoning_item_blocks(item).map { |thinking| { type: "thinking", thinking: thinking } }
175
175
  when "message"
176
- next [] if ToolCall.value(item, :phase).to_s == "commentary"
177
-
178
176
  text = response_message_item_text(item)
179
- text.empty? ? [] : [{ type: "text", text: text }]
177
+ next [] if text.empty?
178
+
179
+ if ToolCall.value(item, :phase).to_s == "commentary"
180
+ [{ type: "thinking", thinking: text }]
181
+ else
182
+ [{ type: "text", text: text }]
183
+ end
180
184
  else
181
185
  []
182
186
  end
@@ -0,0 +1,74 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Names, aliases, and display paths accepted by the scratchpad command.
4
+ module ScratchpadLanguages
5
+ LANGUAGES = {
6
+ text: { aliases: %w[text txt], display_path: "scratchpad.txt" },
7
+ ruby: { aliases: %w[ruby rb], display_path: "scratchpad.rb", runner: :ruby },
8
+ erb: { aliases: %w[erb], display_path: "scratchpad.erb" },
9
+ crystal: { aliases: %w[crystal cr], display_path: "scratchpad.cr", runner: :crystal },
10
+ elixir: { aliases: %w[elixir ex exs], display_path: "scratchpad.ex", runner: :elixir },
11
+ julia: { aliases: %w[julia jl], display_path: "scratchpad.jl", runner: :julia },
12
+ javascript: { aliases: %w[javascript js jsx mjs cjs], display_path: "scratchpad.js", runner: :node },
13
+ typescript: { aliases: %w[typescript ts tsx], display_path: "scratchpad.ts", runner: :node },
14
+ json: { aliases: %w[json], display_path: "scratchpad.json" },
15
+ markdown: { aliases: %w[markdown md], display_path: "scratchpad.md" },
16
+ yaml: { aliases: %w[yaml yml], display_path: "scratchpad.yml" },
17
+ shell: { aliases: %w[shell sh bash zsh fish], display_path: "scratchpad.sh", runner: :shell },
18
+ makefile: { aliases: %w[makefile make mk], display_path: "scratchpad.mk" },
19
+ html: { aliases: %w[html htm], display_path: "scratchpad.html" },
20
+ css: { aliases: %w[css], display_path: "scratchpad.css" },
21
+ scss: { aliases: %w[scss], display_path: "scratchpad.scss" },
22
+ python: { aliases: %w[python py pyw], display_path: "scratchpad.py", runner: :python },
23
+ go: { aliases: %w[go], display_path: "scratchpad.go", runner: :go },
24
+ rust: { aliases: %w[rust rs], display_path: "scratchpad.rs" },
25
+ java: { aliases: %w[java], display_path: "scratchpad.java" },
26
+ csharp: { aliases: %w[csharp cs c#], display_path: "scratchpad.cs" },
27
+ c: { aliases: %w[c], display_path: "scratchpad.c" },
28
+ cpp: { aliases: %w[cpp cc cxx hpp hh hxx c++], display_path: "scratchpad.cpp" },
29
+ swift: { aliases: %w[swift], display_path: "scratchpad.swift", runner: :swift },
30
+ kotlin: { aliases: %w[kotlin kt kts], display_path: "scratchpad.kt" },
31
+ lua: { aliases: %w[lua], display_path: "scratchpad.lua", runner: :lua },
32
+ sql: { aliases: %w[sql], display_path: "scratchpad.sql" }
33
+ }.freeze
34
+
35
+ ALIASES = LANGUAGES.each_with_object({}) do |(language, definition), aliases|
36
+ ([language.to_s] + definition.fetch(:aliases)).each do |name|
37
+ aliases[name] = language
38
+ end
39
+ end.freeze
40
+
41
+ module_function
42
+
43
+ def normalize(language)
44
+ value = language.to_s.strip.downcase
45
+ return :text if value.empty?
46
+
47
+ ALIASES.fetch(value) do
48
+ raise ArgumentError, "Unknown scratchpad language #{language.inspect}. Use /scratchpad help for supported languages."
49
+ end
50
+ end
51
+
52
+ def display_path(language)
53
+ LANGUAGES.fetch(normalize(language)).fetch(:display_path)
54
+ end
55
+
56
+ def runner(language)
57
+ LANGUAGES.fetch(normalize(language))[:runner]
58
+ end
59
+
60
+ def runnable?(language)
61
+ !runner(language).nil?
62
+ end
63
+
64
+ def help_text
65
+ lines = ["Supported scratchpad languages:"]
66
+ LANGUAGES.each do |language, definition|
67
+ aliases = definition.fetch(:aliases).reject { |name| name == language.to_s }
68
+ suffix = aliases.empty? ? "" : " (#{aliases.join(", ")})"
69
+ lines << " #{language}#{suffix}"
70
+ end
71
+ lines.join("\n")
72
+ end
73
+ end
74
+ end