kward 0.71.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -65,6 +65,7 @@ module Kward
65
65
  def finish_question_prompt(saved_state)
66
66
  @mutex.synchronize do
67
67
  @question_state = nil
68
+ @question_prompt_active = false
68
69
  @select_state = saved_state[:select_state]
69
70
  @prompt_label = saved_state[:prompt_label]
70
71
  self.composer_input = saved_state[:input]
@@ -81,20 +82,17 @@ module Kward
81
82
 
82
83
  def handle_question_key(key)
83
84
  return if handle_question_bracketed_paste_key(key)
85
+ return if handle_question_shift_enter_key(key)
84
86
 
85
87
  csi_result = handle_question_csi_u_key(key)
86
88
  return csi_result unless csi_result == false
87
89
 
88
- if key.is_a?(String) && key.length > 1
89
- token = next_key_token(key)
90
- if token.length < key.length
91
- queue_pending_keys(key[token.length..])
92
- return handle_question_key(token)
93
- end
94
- end
90
+ return true if handle_bundled_key(key) { |token| handle_question_key(token) }
91
+
92
+ binding_result = handle_question_composer_key_binding(key)
93
+ return binding_result unless binding_result == false
95
94
 
96
- key_name = @reader.console.keys[key]
97
- case key_name
95
+ case key_name_for(key)
98
96
  when :return, :enter
99
97
  current_question_answer
100
98
  when :backspace
@@ -102,13 +100,13 @@ module Kward
102
100
  when :delete
103
101
  question_delete_at_cursor
104
102
  when :left
105
- @composer.move_cursor_left
103
+ move_cursor_left
106
104
  when :right
107
- @composer.move_cursor_right
105
+ move_cursor_right
108
106
  when :home
109
- @composer.move_to_start_of_line
107
+ move_to_start_of_line
110
108
  when :end
111
- @composer.move_to_end_of_line
109
+ move_to_end_of_line
112
110
  when :up
113
111
  question_previous_choice
114
112
  when :down
@@ -132,21 +130,45 @@ module Kward
132
130
  return false unless sequence
133
131
 
134
132
  code = sequence[:code]
133
+ modifier = sequence[:modifier]
135
134
  queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
136
135
 
137
136
  case code
138
137
  when 13
139
- current_question_answer
138
+ if modifier == 2
139
+ question_insert_string("\n")
140
+ nil
141
+ else
142
+ current_question_answer
143
+ end
140
144
  when 27
141
145
  SELECT_CANCEL
142
146
  when 8, 127
143
- question_delete_before_cursor
147
+ alt_modifier?(modifier) ? question_delete_word_before_cursor : question_delete_before_cursor
144
148
  nil
145
149
  else
146
- false
150
+ modified_result = handle_question_modified_csi_u_key(code, modifier)
151
+ return modified_result unless modified_result == false
152
+
153
+ question_insert_csi_u_text(sequence)
147
154
  end
148
155
  end
149
156
 
157
+ def handle_question_modified_csi_u_key(code, modifier)
158
+ before = composer_input.dup
159
+ result = handle_modified_csi_u_key(code, modifier)
160
+ question_select_custom_choice if result != false && composer_input != before
161
+ result
162
+ end
163
+
164
+ def question_insert_csi_u_text(sequence)
165
+ text = csi_u_printable_text(sequence)
166
+ return true if text.nil? && csi_u_text_field?(sequence)
167
+ return false unless text
168
+
169
+ question_insert_string(text)
170
+ end
171
+
150
172
  def handle_question_escape_sequence
151
173
  pending_sequence = read_pending_escape_sequence
152
174
  return SELECT_CANCEL if pending_sequence.empty?
@@ -156,16 +178,18 @@ module Kward
156
178
  queue_pending_keys(full_sequence[sequence.length..]) if full_sequence.length > sequence.length
157
179
  return SELECT_CANCEL if sequence == "\e"
158
180
 
159
- key_name = @reader.console.keys[sequence]
160
- case key_name
181
+ binding_result = handle_question_composer_key_binding(sequence)
182
+ return binding_result unless binding_result == false
183
+
184
+ case key_name_for(sequence)
161
185
  when :up
162
186
  question_previous_choice
163
187
  when :down
164
188
  question_next_choice
165
189
  when :left
166
- @composer.move_cursor_left
190
+ move_cursor_left
167
191
  when :right
168
- @composer.move_cursor_right
192
+ move_cursor_right
169
193
  end
170
194
  true
171
195
  end
@@ -244,34 +268,78 @@ module Kward
244
268
  question_insert_string(key)
245
269
  end
246
270
 
271
+ def handle_question_shift_enter_key(key)
272
+ sequence = shift_enter_sequence_for(key)
273
+ return false unless sequence
274
+
275
+ question_insert_string("\n")
276
+ queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
277
+ true
278
+ end
279
+
280
+ def handle_question_composer_key_binding(key)
281
+ before = composer_input.dup
282
+ result = handle_composer_key_binding(key)
283
+ question_select_custom_choice if result != false && composer_input != before
284
+ result
285
+ end
286
+
247
287
  def question_insert_string(string)
248
288
  return if string.empty?
249
289
 
250
- @composer.insert_string(string)
251
- @question_state[:selection_index] = question_choices.length - 1 if @question_state
290
+ insert_string(string)
291
+ question_select_custom_choice
252
292
  end
253
293
 
254
294
  def question_delete_before_cursor
255
- return unless @composer.delete_before_cursor
256
-
257
- @question_state[:selection_index] = question_choices.length - 1 if @question_state && !composer_input.empty?
295
+ before = composer_input.dup
296
+ delete_before_cursor
297
+ question_select_custom_choice if composer_input != before && !composer_input.empty?
258
298
  end
259
299
 
260
300
  def question_delete_at_cursor
261
- return unless @composer.delete_at_cursor
301
+ before = composer_input.dup
302
+ delete_at_cursor
303
+ question_select_custom_choice if composer_input != before && !composer_input.empty?
304
+ end
262
305
 
263
- @question_state[:selection_index] = question_choices.length - 1 if @question_state && !composer_input.empty?
306
+ def question_delete_word_before_cursor
307
+ before = composer_input.dup
308
+ delete_word_before_cursor
309
+ question_select_custom_choice if composer_input != before && !composer_input.empty?
310
+ end
311
+
312
+ def question_select_custom_choice
313
+ @question_state[:selection_index] = question_choices.length - 1 if @question_state
264
314
  end
265
315
 
266
316
  def question_composer_layout(width, height = screen_height)
267
317
  content_width = [width - 4, 1].max
268
318
  overlay_rows = active_overlay_rows(width, height: height)
269
- rows = overlay_rows + [top_border(width), box_content_row("", content_width), bottom_border(width)]
270
- return [rows, question_custom_cursor_row, question_custom_cursor_col(width)] if selected_question_choice&.fetch(:custom, false)
319
+ return question_custom_composer_layout(width, height, overlay_rows, content_width) if selected_question_choice&.fetch(:custom, false)
271
320
 
321
+ rows = overlay_rows + [top_border(width), box_content_row("", content_width)]
322
+ rows.concat(question_bottom_border_rows(width))
272
323
  [rows, overlay_rows.length + 1, 2]
273
324
  end
274
325
 
326
+ def question_custom_composer_layout(width, height, overlay_rows, content_width)
327
+ input_layout_rows, input_cursor_row, input_cursor_col = input_layout(content_width)
328
+ max_input_rows = max_visible_input_rows(0, overlay_rows.length, 0, height: height)
329
+ visible_start = [[input_cursor_row - max_input_rows + 1, 0].max, [input_layout_rows.length - max_input_rows, 0].max].min
330
+ visible_rows = input_layout_rows[visible_start, max_input_rows] || [""]
331
+ rows = overlay_rows + [top_border(width)]
332
+ rows.concat(visible_rows.map { |row| box_content_row(row, content_width) })
333
+ rows.concat(question_bottom_border_rows(width))
334
+ cursor_row = overlay_rows.length + 1 + input_cursor_row - visible_start
335
+ cursor_col = 2 + [input_cursor_col, content_width - 1].min
336
+ [rows, cursor_row, cursor_col]
337
+ end
338
+
339
+ def question_bottom_border_rows(width)
340
+ @tabs.empty? ? [bottom_border(width)] : tab_border_rows(width)
341
+ end
342
+
275
343
  def question_overlay_rows(width)
276
344
  title = "Question #{@question_state[:index]}/#{@question_state[:total]} · #{@question_state[:header]}"
277
345
  lines = [
@@ -286,35 +354,15 @@ module Kward
286
354
  overlay_card_rows(title, lines, width)
287
355
  end
288
356
 
289
- def question_custom_cursor_row
290
- 4 + question_choices.index { |choice| choice[:custom] }.to_i
291
- end
292
-
293
- def question_custom_cursor_col(width)
294
- card_width = overlay_card_width(width)
295
- left_padding = overlay_left_padding(width, card_width)
296
- custom_prefix = selected_question_choice&.fetch(:custom, false) || !composer_input.empty? ? "Type something: " : "Type something."
297
- visible_before_cursor = display_question_input(composer_input[0...composer_cursor])
298
- [[left_padding + 2 + 2 + custom_prefix.length + visible_before_cursor.length, width - 1].min, 0].max
299
- end
300
-
301
357
  def choice_text(choice, selected: false)
302
358
  if choice[:custom]
303
- if selected || !composer_input.empty?
304
- "Type something: #{display_question_input(composer_input)}"
305
- else
306
- "Type something."
307
- end
359
+ selected ? "Type a custom answer below." : "Type something."
308
360
  else
309
361
  description = choice[:description].empty? ? "" : " — #{choice[:description]}"
310
362
  "#{choice[:label]}#{description}"
311
363
  end
312
364
  end
313
365
 
314
- def display_question_input(value)
315
- value.to_s.gsub(/\s+/, " ")
316
- end
317
-
318
366
  end
319
367
  end
320
368
  end
@@ -50,6 +50,21 @@ module Kward
50
50
  true
51
51
  end
52
52
 
53
+ def cached_composer_status_text
54
+ return nil unless @composer_status
55
+
56
+ now = monotonic_now
57
+ elapsed = now - @last_composer_status_refresh.to_f
58
+ if @cached_composer_status_text.nil? || elapsed >= COMPOSER_STATUS_REFRESH_INTERVAL
59
+ text = @composer_status.call.to_s
60
+ @cached_composer_status_text = text.empty? ? nil : status_composer_text(text)
61
+ @last_composer_status_refresh = now
62
+ end
63
+ @cached_composer_status_text
64
+ rescue StandardError
65
+ @cached_composer_status_text = nil
66
+ end
67
+
53
68
  def monotonic_now
54
69
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
55
70
  end
@@ -59,6 +74,34 @@ module Kward
59
74
  ANSI.colorize(text, *styles, enabled: @color_enabled)
60
75
  end
61
76
 
77
+ def normalize_tab_keybindings(value)
78
+ text = value.to_s.downcase
79
+ return "ctrl" if text == "ctrl"
80
+ return "alt" if text == "alt"
81
+
82
+ RbConfig::CONFIG["host_os"].to_s.downcase.include?("darwin") ? "ctrl" : "alt"
83
+ end
84
+
85
+ def normalize_editor_mode(value)
86
+ EditorMode.normalize(value)
87
+ end
88
+
89
+ def normalize_editor_line_numbers(value)
90
+ EditorMode.normalize_line_numbers(value)
91
+ end
92
+
93
+ def tab_action_result?(result)
94
+ result.is_a?(Hash) && result[:tab_action]
95
+ end
96
+
97
+ def reasoning_action_result?(result)
98
+ result.is_a?(Hash) && result[:reasoning_action]
99
+ end
100
+
101
+ def prompt_action_result?(result)
102
+ tab_action_result?(result) || reasoning_action_result?(result)
103
+ end
104
+
62
105
  end
63
106
  end
64
107
  end
@@ -56,6 +56,8 @@ module Kward
56
56
  def render_cursor_visibility_locked
57
57
  visible = !(@question_state && !selected_question_choice&.fetch(:custom, false))
58
58
  visible = select_editing_active? if @select_state
59
+ visible = git_composing? if @git_state
60
+ visible = project_browser_search_active? if project_browser_visible?
59
61
  set_cursor_visible_locked(visible)
60
62
  end
61
63
 
@@ -66,6 +68,20 @@ module Kward
66
68
  @cursor_visible = visible
67
69
  end
68
70
 
71
+ def set_editor_bar_cursor_locked
72
+ return if @editor_bar_cursor_active
73
+
74
+ @output_io.print(CURSOR_SHAPE_BAR)
75
+ @editor_bar_cursor_active = true
76
+ end
77
+
78
+ def restore_editor_cursor_shape_locked
79
+ return unless @editor_bar_cursor_active
80
+
81
+ @output_io.print(CURSOR_SHAPE_DEFAULT)
82
+ @editor_bar_cursor_active = false
83
+ end
84
+
69
85
  def reserve_composer_region_locked(width: screen_width, height: screen_height)
70
86
  rows, = composer_layout(width, height)
71
87
  ensure_scroll_region_locked(rows.length, width: width, height: height)
@@ -10,13 +10,7 @@ module Kward
10
10
  return select_current_choice if key.nil?
11
11
  return if handle_select_bracketed_paste_key(key)
12
12
 
13
- if key.is_a?(String) && key.length > 1
14
- token = next_key_token(key)
15
- if token.length < key.length
16
- queue_pending_keys(key[token.length..])
17
- return handle_select_key(token)
18
- end
19
- end
13
+ return true if handle_bundled_key(key) { |token| handle_select_key(token) }
20
14
 
21
15
  return handle_select_confirmation_key(key) if select_confirmation_active?
22
16
 
@@ -101,7 +95,7 @@ module Kward
101
95
  modified_result = handle_select_modified_csi_u_key(code, modifier)
102
96
  return modified_result unless modified_result == false
103
97
 
104
- handle_select_printable_csi_u_key(code, modifiers)
98
+ handle_select_printable_csi_u_key(sequence)
105
99
  end
106
100
  end
107
101
 
@@ -119,12 +113,12 @@ module Kward
119
113
  end
120
114
  end
121
115
 
122
- def handle_select_printable_csi_u_key(code, modifiers)
123
- return false unless modifiers.empty? || modifiers == "1"
124
- return false unless code.between?(32, 126)
116
+ def handle_select_printable_csi_u_key(sequence)
117
+ text = csi_u_printable_text(sequence)
118
+ return true if text.nil? && csi_u_text_field?(sequence)
119
+ return false unless text
125
120
 
126
- key = code.chr(Encoding::UTF_8)
127
- select_typed_key(key)
121
+ select_typed_key(text)
128
122
  end
129
123
 
130
124
  def handle_select_escape_sequence
@@ -10,6 +10,13 @@ module Kward
10
10
  reset
11
11
  end
12
12
 
13
+ def initialize_copy(source)
14
+ super
15
+ @block = source.block&.dup
16
+ @col = source.col
17
+ @pending_wrap = source.pending_wrap?
18
+ end
19
+
13
20
  def reset
14
21
  @block = nil
15
22
  @col = 0
@@ -13,6 +13,12 @@ module Kward
13
13
  @display_rows_cache = nil
14
14
  end
15
15
 
16
+ def initialize_copy(source)
17
+ super
18
+ @text = source.text.dup
19
+ invalidate_display_rows_cache
20
+ end
21
+
16
22
  def to_s
17
23
  @text
18
24
  end