kward 0.70.0 → 0.71.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +48 -2
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +30 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +43 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +39 -25
  11. data/doc/configuration.md +1 -15
  12. data/doc/context-tools.md +70 -0
  13. data/doc/plugins.md +2 -2
  14. data/doc/releasing.md +14 -5
  15. data/doc/rpc.md +3 -11
  16. data/doc/session-management.md +220 -0
  17. data/doc/usage.md +7 -8
  18. data/doc/workspace-tools.md +105 -0
  19. data/lib/kward/cli/commands.rb +8 -0
  20. data/lib/kward/cli/openrouter_commands.rb +55 -0
  21. data/lib/kward/cli/prompt_interface.rb +80 -6
  22. data/lib/kward/cli/rendering.rb +11 -6
  23. data/lib/kward/cli/sessions.rb +260 -11
  24. data/lib/kward/cli/settings.rb +0 -30
  25. data/lib/kward/cli/slash_commands.rb +24 -6
  26. data/lib/kward/cli.rb +13 -0
  27. data/lib/kward/compactor.rb +4 -1
  28. data/lib/kward/config_files.rb +4 -6
  29. data/lib/kward/conversation.rb +49 -20
  30. data/lib/kward/model/client.rb +37 -50
  31. data/lib/kward/model/context_usage.rb +13 -6
  32. data/lib/kward/model/model_info.rb +92 -16
  33. data/lib/kward/model/payloads.rb +2 -0
  34. data/lib/kward/openrouter_model_cache.rb +120 -0
  35. data/lib/kward/plugin_registry.rb +47 -1
  36. data/lib/kward/prompt_interface/banner.rb +16 -51
  37. data/lib/kward/prompt_interface/composer_controller.rb +60 -87
  38. data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
  39. data/lib/kward/prompt_interface/key_handler.rb +31 -10
  40. data/lib/kward/prompt_interface/layout.rb +2 -2
  41. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  42. data/lib/kward/prompt_interface/question_prompt.rb +34 -42
  43. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  44. data/lib/kward/prompt_interface/screen.rb +1 -0
  45. data/lib/kward/prompt_interface/selection_prompt.rb +513 -54
  46. data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
  47. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  48. data/lib/kward/prompt_interface.rb +22 -28
  49. data/lib/kward/prompts/commands.rb +2 -1
  50. data/lib/kward/prompts.rb +2 -2
  51. data/lib/kward/rpc/server.rb +3 -8
  52. data/lib/kward/rpc/session_manager.rb +17 -6
  53. data/lib/kward/session_store.rb +23 -4
  54. data/lib/kward/telemetry/logger.rb +5 -3
  55. data/lib/kward/tool_output_compactor.rb +127 -0
  56. data/lib/kward/tools/base.rb +8 -2
  57. data/lib/kward/tools/registry.rb +37 -6
  58. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  59. data/lib/kward/tools/search/web.rb +2 -2
  60. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  61. data/lib/kward/tools/tool_call.rb +2 -0
  62. data/lib/kward/version.rb +1 -1
  63. data/lib/kward/workspace.rb +58 -2
  64. data/templates/default/fulldoc/html/css/kward.css +256 -7
  65. data/templates/default/fulldoc/html/full_list.erb +107 -0
  66. data/templates/default/fulldoc/html/js/kward.js +161 -2
  67. data/templates/default/fulldoc/html/setup.rb +8 -0
  68. data/templates/default/kward_navigation.rb +91 -0
  69. data/templates/default/layout/html/layout.erb +39 -8
  70. data/templates/default/layout/html/setup.rb +33 -38
  71. metadata +13 -3
  72. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  73. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -6,25 +6,44 @@ module Kward
6
6
  module PromptRenderer
7
7
  private
8
8
 
9
- def render_prompt_locked
9
+ def render_prompt_locked(synchronized: false)
10
10
  return unless @started && @asking
11
11
 
12
- with_synchronized_output_locked do
13
- handle_resize_locked
14
- width, height = screen_size
15
- rows, cursor_row, cursor_col = composer_layout(width, height)
16
- ensure_scroll_region_locked(rows.length, width: width, height: height)
17
- @rendered_rows = rows.length
18
- render_composer_rows_locked(rows, height: height)
19
- @cursor_rendered_row = cursor_row
20
- @last_width = width
21
- @last_height = height
22
- move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
23
- render_cursor_visibility_locked
12
+ width, height = screen_size
13
+ if width != @last_width || height != @last_height
14
+ with_synchronized_output_locked { render_prompt_body_locked }
15
+ @output_io.flush
16
+ return
17
+ end
18
+
19
+ rows, cursor_row, cursor_col = composer_layout(width, height)
20
+ synchronized ||= rows.length != @reserved_rows
21
+ if synchronized
22
+ with_synchronized_output_locked { render_prompt_layout_locked(rows, cursor_row, cursor_col, width, height) }
23
+ else
24
+ render_prompt_layout_locked(rows, cursor_row, cursor_col, width, height)
24
25
  end
25
26
  @output_io.flush
26
27
  end
27
28
 
29
+ def render_prompt_body_locked
30
+ handle_resize_locked
31
+ width, height = screen_size
32
+ rows, cursor_row, cursor_col = composer_layout(width, height)
33
+ render_prompt_layout_locked(rows, cursor_row, cursor_col, width, height)
34
+ end
35
+
36
+ def render_prompt_layout_locked(rows, cursor_row, cursor_col, width, height)
37
+ ensure_scroll_region_locked(rows.length, width: width, height: height)
38
+ @rendered_rows = rows.length
39
+ render_composer_rows_locked(rows, height: height)
40
+ @cursor_rendered_row = cursor_row
41
+ @last_width = width
42
+ @last_height = height
43
+ move_to_screen(composer_top_row(height) + cursor_row, cursor_col + 1)
44
+ render_cursor_visibility_locked
45
+ end
46
+
28
47
  def render_prompt_after_output_locked
29
48
  render_prompt_locked
30
49
  end
@@ -8,13 +8,7 @@ module Kward
8
8
 
9
9
  def ask_single_user_question(question, index, total)
10
10
  @mutex.synchronize do
11
- @prompt_label = "Answer>"
12
- self.composer_input = ""
13
- self.composer_cursor = 0
14
- @pending_keys.clear
15
- @asking = true
16
- @busy = false
17
- @queued_count = 0
11
+ prepare_modal_input_locked("Answer>")
18
12
  @question_state = {
19
13
  question: question[:question] || question["question"],
20
14
  header: question[:header] || question["header"],
@@ -23,7 +17,6 @@ module Kward
23
17
  index: index,
24
18
  total: total
25
19
  }
26
- reset_history_navigation
27
20
  render_prompt_locked
28
21
  end
29
22
 
@@ -37,6 +30,7 @@ module Kward
37
30
  render_prompt_locked if resized || footer_refreshed
38
31
  else
39
32
  result = handle_question_key(key)
33
+ result = drain_pending_question_keys_locked(result)
40
34
  render_prompt_locked unless result.is_a?(Hash) || result == SELECT_CANCEL
41
35
  end
42
36
  end
@@ -47,6 +41,13 @@ module Kward
47
41
  end
48
42
  end
49
43
 
44
+ def drain_pending_question_keys_locked(result)
45
+ until result.is_a?(Hash) || result == SELECT_CANCEL || @pending_keys.empty?
46
+ result = handle_question_key(@pending_keys.shift)
47
+ end
48
+ result
49
+ end
50
+
50
51
  def begin_question_prompt_state
51
52
  {
52
53
  prompt_label: @prompt_label,
@@ -101,13 +102,13 @@ module Kward
101
102
  when :delete
102
103
  question_delete_at_cursor
103
104
  when :left
104
- self.composer_cursor -= 1 if composer_cursor.positive?
105
+ @composer.move_cursor_left
105
106
  when :right
106
- self.composer_cursor += 1 if composer_cursor < composer_input.length
107
+ @composer.move_cursor_right
107
108
  when :home
108
- self.composer_cursor = 0
109
+ @composer.move_to_start_of_line
109
110
  when :end
110
- self.composer_cursor = composer_input.length
111
+ @composer.move_to_end_of_line
111
112
  when :up
112
113
  question_previous_choice
113
114
  when :down
@@ -127,12 +128,11 @@ module Kward
127
128
  end
128
129
 
129
130
  def handle_question_csi_u_key(key)
130
- match = key.to_s.match(/\A\e\[(\d+)(?:;([\d:]+))?u/)
131
- return false unless match
131
+ sequence = parse_csi_u_key(key)
132
+ return false unless sequence
132
133
 
133
- sequence = match[0]
134
- code = match[1].to_i
135
- queue_pending_keys(key[sequence.length..]) if key.length > sequence.length
134
+ code = sequence[:code]
135
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
136
136
 
137
137
  case code
138
138
  when 13
@@ -148,38 +148,34 @@ module Kward
148
148
  end
149
149
 
150
150
  def handle_question_escape_sequence
151
- sequence = read_pending_escape_sequence
152
- return SELECT_CANCEL if sequence.empty?
151
+ pending_sequence = read_pending_escape_sequence
152
+ return SELECT_CANCEL if pending_sequence.empty?
153
+
154
+ full_sequence = "\e#{pending_sequence}"
155
+ sequence = next_key_token(full_sequence)
156
+ queue_pending_keys(full_sequence[sequence.length..]) if full_sequence.length > sequence.length
157
+ return SELECT_CANCEL if sequence == "\e"
153
158
 
154
- key_name = @reader.console.keys["\e#{sequence}"]
159
+ key_name = @reader.console.keys[sequence]
155
160
  case key_name
156
161
  when :up
157
162
  question_previous_choice
158
163
  when :down
159
164
  question_next_choice
160
165
  when :left
161
- self.composer_cursor -= 1 if composer_cursor.positive?
166
+ @composer.move_cursor_left
162
167
  when :right
163
- self.composer_cursor += 1 if composer_cursor < composer_input.length
168
+ @composer.move_cursor_right
164
169
  end
165
170
  true
166
171
  end
167
172
 
168
173
  def handle_question_bracketed_paste_key(key)
169
- text = key.to_s
170
- return false unless text.start_with?(BRACKETED_PASTE_START)
171
-
172
- pasted = text[BRACKETED_PASTE_START.length..] || ""
173
- until pasted.include?(BRACKETED_PASTE_END)
174
- chunk = @reader.read_keypress(echo: false, raw: true)
175
- break if chunk.nil?
176
-
177
- pasted << chunk.to_s
178
- end
174
+ paste = read_bracketed_paste(key)
175
+ return false unless paste
179
176
 
180
- content, remaining = pasted.split(BRACKETED_PASTE_END, 2)
181
- question_insert_string(normalize_paste(content || ""))
182
- queue_pending_keys(remaining) if remaining && !remaining.empty?
177
+ question_insert_string(normalize_paste(paste[:content]))
178
+ queue_pending_keys(paste[:remaining]) if paste[:remaining] && !paste[:remaining].empty?
183
179
  true
184
180
  end
185
181
 
@@ -251,23 +247,19 @@ module Kward
251
247
  def question_insert_string(string)
252
248
  return if string.empty?
253
249
 
254
- self.composer_input = composer_input[0...composer_cursor] + string + composer_input[composer_cursor..]
255
- self.composer_cursor += string.length
250
+ @composer.insert_string(string)
256
251
  @question_state[:selection_index] = question_choices.length - 1 if @question_state
257
252
  end
258
253
 
259
254
  def question_delete_before_cursor
260
- return unless composer_cursor.positive?
255
+ return unless @composer.delete_before_cursor
261
256
 
262
- self.composer_input = composer_input[0...(composer_cursor - 1)] + composer_input[composer_cursor..]
263
- self.composer_cursor -= 1
264
257
  @question_state[:selection_index] = question_choices.length - 1 if @question_state && !composer_input.empty?
265
258
  end
266
259
 
267
260
  def question_delete_at_cursor
268
- return unless composer_cursor < composer_input.length
261
+ return unless @composer.delete_at_cursor
269
262
 
270
- self.composer_input = composer_input[0...composer_cursor] + composer_input[(composer_cursor + 1)..]
271
263
  @question_state[:selection_index] = question_choices.length - 1 if @question_state && !composer_input.empty?
272
264
  end
273
265
 
@@ -17,7 +17,7 @@ module Kward
17
17
  end
18
18
 
19
19
  def tick_spinner_locked
20
- return false unless @busy && @queued_count.zero? && @started && @asking
20
+ return false unless spinner_active?
21
21
 
22
22
  now = monotonic_now
23
23
  elapsed = now - @last_spinner_tick
@@ -33,6 +33,11 @@ module Kward
33
33
  SPINNER_FRAMES[@spinner_frame_index % SPINNER_FRAMES.length]
34
34
  end
35
35
 
36
+ def spinner_active?
37
+ busy = @busy || (@select_state && @select_state[:busy_activity])
38
+ busy && @queued_count.zero? && @started && @asking
39
+ end
40
+
36
41
  def tick_footer_locked
37
42
  return false unless @footer && @started && @asking
38
43
 
@@ -55,6 +55,7 @@ module Kward
55
55
 
56
56
  def render_cursor_visibility_locked
57
57
  visible = !(@question_state && !selected_question_choice&.fetch(:custom, false))
58
+ visible = select_editing_active? if @select_state
58
59
  set_cursor_visible_locked(visible)
59
60
  end
60
61