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
@@ -36,8 +36,12 @@ module Kward
36
36
  return if string.empty?
37
37
 
38
38
  reset_slash_selection
39
+ reset_file_selection
39
40
  reset_history_navigation
40
41
  @slash_overlay_dismissed_input = nil
42
+ @file_overlay_dismissed_token = nil
43
+ @file_open_dismissed_token = nil
44
+ @file_editor_open_status = nil
41
45
  @composer.insert_string(string)
42
46
  end
43
47
 
@@ -72,7 +76,11 @@ module Kward
72
76
  end
73
77
 
74
78
  reset_slash_selection
79
+ reset_file_selection
75
80
  reset_history_navigation
81
+ @file_overlay_dismissed_token = nil
82
+ @file_open_dismissed_token = nil
83
+ @file_editor_open_status = nil
76
84
  @composer.delete_before_cursor
77
85
  end
78
86
 
@@ -80,16 +88,24 @@ module Kward
80
88
  return unless @composer.remove_last_attachment
81
89
 
82
90
  reset_slash_selection
91
+ reset_file_selection
83
92
  reset_history_navigation
84
93
  @slash_overlay_dismissed_input = nil
94
+ @file_overlay_dismissed_token = nil
95
+ @file_open_dismissed_token = nil
96
+ @file_editor_open_status = nil
85
97
  end
86
98
 
87
99
  def delete_at_cursor
88
100
  return unless @composer.cursor < @composer.input.length
89
101
 
90
102
  reset_slash_selection
103
+ reset_file_selection
91
104
  reset_history_navigation
92
105
  @slash_overlay_dismissed_input = nil
106
+ @file_overlay_dismissed_token = nil
107
+ @file_open_dismissed_token = nil
108
+ @file_editor_open_status = nil
93
109
  @composer.delete_at_cursor
94
110
  end
95
111
 
@@ -123,25 +139,33 @@ module Kward
123
139
 
124
140
  def delete_word_before_cursor
125
141
  reset_slash_selection
142
+ reset_file_selection
126
143
  reset_history_navigation
144
+ @file_overlay_dismissed_token = nil
127
145
  @composer.delete_word_before_cursor
128
146
  end
129
147
 
130
148
  def delete_word_after_cursor
131
149
  reset_slash_selection
150
+ reset_file_selection
132
151
  reset_history_navigation
152
+ @file_overlay_dismissed_token = nil
133
153
  @composer.delete_word_after_cursor
134
154
  end
135
155
 
136
156
  def kill_line_before_cursor
137
157
  reset_slash_selection
158
+ reset_file_selection
138
159
  reset_history_navigation
160
+ @file_overlay_dismissed_token = nil
139
161
  @composer.kill_line_before_cursor
140
162
  end
141
163
 
142
164
  def kill_line_after_cursor
143
165
  reset_slash_selection
166
+ reset_file_selection
144
167
  reset_history_navigation
168
+ @file_overlay_dismissed_token = nil
145
169
  @composer.kill_line_after_cursor
146
170
  end
147
171
 
@@ -149,8 +173,14 @@ module Kward
149
173
  @composer.yank_kill_buffer
150
174
  end
151
175
 
176
+ def load_history(values)
177
+ @composer.load_history(values)
178
+ end
179
+
152
180
  def add_history(value)
153
- @composer.add_history(value)
181
+ added = @composer.add_history(value)
182
+ @prompt_history&.append(value) if added
183
+ added
154
184
  end
155
185
 
156
186
  def recall_previous_history
@@ -161,6 +191,38 @@ module Kward
161
191
  @composer.recall_next_history
162
192
  end
163
193
 
194
+ def start_history_search
195
+ @composer.start_history_search
196
+ end
197
+
198
+ def history_search_active?
199
+ @composer.history_search_active?
200
+ end
201
+
202
+ def update_history_search_query(value)
203
+ @composer.update_history_search_query(value)
204
+ end
205
+
206
+ def history_search_matches
207
+ @composer.history_search_matches
208
+ end
209
+
210
+ def accept_history_search
211
+ @composer.accept_history_search
212
+ end
213
+
214
+ def cancel_history_search
215
+ @composer.cancel_history_search
216
+ end
217
+
218
+ def select_previous_history_search_match
219
+ @composer.select_previous_history_search_match
220
+ end
221
+
222
+ def select_next_history_search_match
223
+ @composer.select_next_history_search_match
224
+ end
225
+
164
226
  def replace_input(value)
165
227
  @composer.replace_input(value)
166
228
  end
@@ -175,6 +237,10 @@ module Kward
175
237
  @composer.reset_history_navigation
176
238
  end
177
239
 
240
+ def reset_history_search
241
+ @composer.reset_history_search
242
+ end
243
+
178
244
  def prepare_modal_input_locked(label, clear_attachments: false)
179
245
  @prompt_label = label.to_s
180
246
  self.composer_input = ""
@@ -202,6 +268,7 @@ module Kward
202
268
  self.composer_cursor = 0
203
269
  @composer.clear_attachments
204
270
  reset_history_navigation if reset_history
271
+ reset_history_search if reset_history
205
272
  @asking = true
206
273
  render_prompt_after_output_locked
207
274
  else
@@ -209,6 +276,7 @@ module Kward
209
276
  self.composer_input = ""
210
277
  self.composer_cursor = 0
211
278
  @composer.clear_attachments
279
+ reset_history_search if reset_history
212
280
  @asking = false
213
281
  @rendered_rows = 0
214
282
  @cursor_rendered_row = 0
@@ -7,6 +7,8 @@ module Kward
7
7
  private
8
8
 
9
9
  def composer_layout(width, height = screen_height)
10
+ return interactive_layout(width, height) if interactive_active_locked?
11
+ return editor_layout(width, height) if editor_active?
10
12
  return compact_composer_layout(width) if height < 4
11
13
  return question_composer_layout(width, height) if @question_state
12
14
 
@@ -22,7 +24,11 @@ module Kward
22
24
  rows.concat(attachment_rows)
23
25
  rows.concat(visible_rows.map { |row| box_content_row(row, content_width) })
24
26
  rows << footer_row(content_width, footer_text) unless footer_text.empty?
25
- rows << bottom_border(width)
27
+ if @tabs.empty?
28
+ rows << bottom_border(width)
29
+ else
30
+ rows.concat(tab_border_rows(width))
31
+ end
26
32
  cursor_row = overlay_rows.length + 1 + attachment_rows.length + input_cursor_row - visible_start
27
33
  cursor_col = 2 + [input_cursor_col, content_width - 1].min
28
34
  [rows, cursor_row, cursor_col]
@@ -73,7 +79,7 @@ module Kward
73
79
 
74
80
  def top_border(width)
75
81
  title = composer_title
76
- status = composer_status_text
82
+ status = cached_composer_status_text
77
83
  if status
78
84
  gap = width - 2 - ANSI.strip(title).length - ANSI.strip(status).length
79
85
  if gap >= 0
@@ -107,13 +113,6 @@ module Kward
107
113
  @busy_help ? "#{text} · #{BUSY_HELP_TEXT}" : text
108
114
  end
109
115
 
110
- def composer_status_text
111
- text = @composer_status&.call.to_s
112
- return nil if text.empty?
113
-
114
- status_composer_text(text)
115
- end
116
-
117
116
  def status_composer_text(text)
118
117
  " #{text} "
119
118
  end
@@ -122,8 +121,106 @@ module Kward
122
121
  colored("╰#{"─" * [width - 2, 0].max}╯", :primary_green)
123
122
  end
124
123
 
124
+ def tab_border_rows(width)
125
+ return [bottom_border(width)] if width < 10
126
+
127
+ slots = tab_slots
128
+ active_slot = slots[@active_tab_index]
129
+ return [bottom_border(width)] unless active_slot
130
+ return [bottom_border(width)] if active_slot[:left] + active_slot[:width] > width
131
+
132
+ [
133
+ color_tab_border(bottom_tab_border_row(width, active_slot)),
134
+ color_tab_border(tab_bar_row(width, slots, active_slot)),
135
+ color_tab_border(active_tab_bottom_row(width, active_slot))
136
+ ]
137
+ end
138
+
139
+ def tab_slots
140
+ label_left = 4
141
+ @tabs.each_with_index.map do |label, index|
142
+ text = tab_label(label, index)
143
+ width = ANSI.strip(text).length
144
+ slot = {
145
+ left: label_left - 2,
146
+ label_left: label_left,
147
+ label: text,
148
+ inner_width: width + 2,
149
+ width: width + 4
150
+ }
151
+ label_left += width + 3
152
+ slot
153
+ end
154
+ end
155
+
156
+ def bottom_tab_border_row(width, active_slot)
157
+ row = Array.new(width, " ")
158
+ place_string(row, 0, "╰#{"─" * [active_slot[:left] - 1, 0].max}╮")
159
+ place_string(row, active_slot[:left] + active_slot[:inner_width] + 1, "╭")
160
+ place_string(row, active_slot[:left] + active_slot[:inner_width] + 2, "─" * [width - active_slot[:left] - active_slot[:inner_width] - 3, 0].max)
161
+ place_string(row, width - 1, "╯")
162
+ row.join
163
+ end
164
+
165
+ def tab_bar_row(width, slots, active_slot)
166
+ row = Array.new(width, " ")
167
+ slots.each do |slot|
168
+ if slot == active_slot
169
+ place_string(row, slot[:left], "│ #{slot[:label]} │")
170
+ else
171
+ place_string(row, slot[:label_left], slot[:label])
172
+ end
173
+ end
174
+ row.join
175
+ end
176
+
177
+ def active_tab_bottom_row(width, active_slot)
178
+ row = Array.new(width, " ")
179
+ place_string(row, active_slot[:left], "╰#{"─" * active_slot[:inner_width]}╯")
180
+ row.join
181
+ end
182
+
183
+ def place_string(row, left, text)
184
+ return if left >= row.length
185
+
186
+ visible_offset = 0
187
+ last_index = nil
188
+ text.to_s.scan(/\e\[[0-9;:]*m|./m).each do |part|
189
+ if part.start_with?("\e")
190
+ index = visible_offset.positive? ? last_index : left
191
+ row[index] = row[index].to_s + part if index&.between?(0, row.length - 1)
192
+ next
193
+ end
194
+
195
+ index = left + visible_offset
196
+ break if index >= row.length
197
+ row[index] = row[index].to_s.sub(/\A /, "") + part unless index.negative?
198
+ last_index = index
199
+ visible_offset += 1
200
+ end
201
+ end
202
+
203
+ def tab_label(label, index)
204
+ tab = normalize_tab_label(label)
205
+ name = tab[:name].empty? ? "Tab" : tab[:name]
206
+ color = tab[:color]
207
+ name = colored(name, color) if color
208
+ "#{index + 1} #{name}"
209
+ end
210
+
211
+ def normalize_tab_label(label)
212
+ return { name: label[:name].to_s, color: label[:color] } if label.is_a?(Hash)
213
+
214
+ { name: label.to_s, color: nil }
215
+ end
216
+
217
+ def color_tab_border(row)
218
+ row.gsub(/[╰╯╭╮│─]/) { |char| colored(char, :primary_green) }
219
+ end
220
+
125
221
  def box_content_row(row, content_width)
126
- "#{colored("│", :primary_green)} #{row[0, content_width].to_s.ljust(content_width)} #{colored("│", :primary_green)}"
222
+ content = visible_ljust(visible_truncate(row, content_width), content_width)
223
+ "#{colored("│", :primary_green)} #{content} #{colored("│", :primary_green)}"
127
224
  end
128
225
 
129
226
  def footer_row(content_width, text = footer_text)
@@ -154,7 +251,7 @@ module Kward
154
251
  []
155
252
  end
156
253
 
157
- def max_visible_input_rows(attachment_count = 0, overlay_count = active_overlay_rows(screen_width).length, footer_count = footer_text.to_s.empty? ? 0 : 1, height: screen_height)
254
+ def max_visible_input_rows(attachment_count, overlay_count, footer_count, height: screen_height)
158
255
  input_cap = [COMPOSER_MAX_INPUT_ROWS - attachment_count, 1].max
159
256
  [[input_cap, height - 3 - overlay_count - footer_count - attachment_count].min, 1].max
160
257
  end
@@ -169,8 +266,7 @@ module Kward
169
266
  end
170
267
 
171
268
  def cursor_logical_position
172
- before_cursor = composer_input[0...composer_cursor]
173
- [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
269
+ @composer.cursor_logical_position
174
270
  end
175
271
 
176
272
  end
@@ -1,3 +1,5 @@
1
+ require_relative "../text_boundary"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # Interactive terminal UI used by the CLI frontend.
@@ -16,6 +18,12 @@ module Kward
16
18
  attr_accessor :history_draft
17
19
  # @return [String, nil] text queued for the next composer prompt
18
20
  attr_accessor :prefill_input
21
+ # @return [String, nil] query typed while searching history
22
+ attr_accessor :history_search_query
23
+ # @return [String, nil] draft restored after canceling history search
24
+ attr_accessor :history_search_draft
25
+ # @return [Integer] active selection index while searching history
26
+ attr_accessor :history_search_index
19
27
  # @return [Array<Hash>] pending image/file attachments submitted with the next turn
20
28
  attr_reader :attachments
21
29
  # @return [Array<String>] submitted input history
@@ -30,6 +38,9 @@ module Kward
30
38
  @history_index = nil
31
39
  @history_draft = nil
32
40
  @prefill_input = nil
41
+ @history_search_query = nil
42
+ @history_search_draft = nil
43
+ @history_search_index = 0
33
44
  end
34
45
 
35
46
  # Removes all pending attachments without changing text input.
@@ -104,22 +115,22 @@ module Kward
104
115
 
105
116
  # Moves the cursor to the previous word boundary.
106
117
  def move_to_previous_word
107
- @cursor = previous_word_boundary(@cursor)
118
+ @cursor = TextBoundary.previous_word_boundary(@input, @cursor)
108
119
  end
109
120
 
110
121
  # Moves the cursor to the next word boundary.
111
122
  def move_to_next_word
112
- @cursor = next_word_boundary(@cursor)
123
+ @cursor = TextBoundary.next_word_boundary(@input, @cursor)
113
124
  end
114
125
 
115
126
  # Kills the word before the cursor into `kill_buffer`.
116
127
  def delete_word_before_cursor
117
- kill_range(previous_word_boundary(@cursor), @cursor)
128
+ kill_range(TextBoundary.previous_word_boundary(@input, @cursor), @cursor)
118
129
  end
119
130
 
120
131
  # Kills the word after the cursor into `kill_buffer`.
121
132
  def delete_word_after_cursor
122
- kill_range(@cursor, next_word_boundary(@cursor))
133
+ kill_range(@cursor, TextBoundary.next_word_boundary(@input, @cursor))
123
134
  end
124
135
 
125
136
  # Kills all text before the cursor into `kill_buffer`.
@@ -147,27 +158,6 @@ module Kward
147
158
  insert_string(@kill_buffer.to_s) unless @kill_buffer.to_s.empty?
148
159
  end
149
160
 
150
- # Finds the start offset of the word before `index`.
151
- def previous_word_boundary(index)
152
- cursor = index
153
- cursor -= 1 while cursor.positive? && word_separator?(@input[cursor - 1])
154
- cursor -= 1 while cursor.positive? && !word_separator?(@input[cursor - 1])
155
- cursor
156
- end
157
-
158
- # Finds the end offset of the word after `index`.
159
- def next_word_boundary(index)
160
- cursor = index
161
- cursor += 1 while cursor < @input.length && word_separator?(@input[cursor])
162
- cursor += 1 while cursor < @input.length && !word_separator?(@input[cursor])
163
- cursor
164
- end
165
-
166
- # Treats whitespace as the only word separator for composer navigation.
167
- def word_separator?(char)
168
- char.to_s.match?(/\s/)
169
- end
170
-
171
161
  # Replaces the full input buffer and places the cursor at the end.
172
162
  def replace_input(value)
173
163
  @input = value.to_s
@@ -180,13 +170,21 @@ module Kward
180
170
  [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
181
171
  end
182
172
 
173
+ # Replaces the in-memory history list with persisted entries.
174
+ def load_history(values)
175
+ @history = Array(values).map(&:to_s).reject { |value| value.strip.empty? }
176
+ reset_history_navigation
177
+ reset_history_search
178
+ end
179
+
183
180
  # Stores a submitted input unless it is blank or duplicates the previous entry.
184
181
  def add_history(value)
185
182
  stripped = value.to_s.strip
186
- return if stripped.empty?
187
- return if @history.last == value
183
+ return false if stripped.empty?
184
+ return false if @history.last == value
188
185
 
189
186
  @history << value
187
+ true
190
188
  end
191
189
 
192
190
  # Replaces input with the previous history entry, preserving the draft first.
@@ -216,6 +214,77 @@ module Kward
216
214
  @history_index = nil
217
215
  @history_draft = nil
218
216
  end
217
+
218
+ def start_history_search
219
+ @history_search_draft = @input if @history_search_query.nil?
220
+ @history_search_query = @input.to_s
221
+ @history_search_index = 0
222
+ replace_input(@history_search_query)
223
+ end
224
+
225
+ def history_search_active?
226
+ !@history_search_query.nil?
227
+ end
228
+
229
+ def update_history_search_query(value)
230
+ @history_search_query = value.to_s
231
+ @history_search_index = 0
232
+ replace_input(@history_search_query)
233
+ end
234
+
235
+ def history_search_matches
236
+ query = @history_search_query.to_s.downcase
237
+ return @history.reverse if query.empty?
238
+
239
+ @history.reverse.select { |value| fuzzy_history_match?(value.downcase, query) }
240
+ end
241
+
242
+ def selected_history_search_match
243
+ matches = history_search_matches
244
+ return nil if matches.empty?
245
+
246
+ matches[[@history_search_index, matches.length - 1].min]
247
+ end
248
+
249
+ def select_previous_history_search_match
250
+ @history_search_index = [@history_search_index - 1, 0].max
251
+ end
252
+
253
+ def select_next_history_search_match
254
+ matches = history_search_matches
255
+ return if matches.empty?
256
+
257
+ @history_search_index = [@history_search_index + 1, matches.length - 1].min
258
+ end
259
+
260
+ def accept_history_search
261
+ match = selected_history_search_match
262
+ replace_input(match) if match
263
+ reset_history_search
264
+ end
265
+
266
+ def cancel_history_search
267
+ replace_input(@history_search_draft.to_s)
268
+ reset_history_search
269
+ end
270
+
271
+ def reset_history_search
272
+ @history_search_query = nil
273
+ @history_search_draft = nil
274
+ @history_search_index = 0
275
+ end
276
+
277
+ def fuzzy_history_match?(value, query)
278
+ query.chars.all? do |char|
279
+ index = value.index(char)
280
+ if index
281
+ value = value[(index + 1)..].to_s
282
+ true
283
+ else
284
+ false
285
+ end
286
+ end
287
+ end
219
288
  end
220
289
  end
221
290
  end
@@ -0,0 +1,123 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Auto-close pair behavior for the built-in composer file editor.
6
+ module EditorAutoClosePairs
7
+ AUTO_CLOSE_PAIRS = {
8
+ "(" => ")",
9
+ "[" => "]",
10
+ "{" => "}",
11
+ "\"" => "\"",
12
+ "'" => "'",
13
+ "`" => "`"
14
+ }.freeze
15
+ AUTO_CLOSE_OPENERS = AUTO_CLOSE_PAIRS.keys.freeze
16
+ AUTO_CLOSE_CLOSERS = AUTO_CLOSE_PAIRS.values.uniq.freeze
17
+ AUTO_CLOSE_QUOTES = ["\"", "'", "`"].freeze
18
+ WORD_CHARACTER = /[[:alnum:]_]/.freeze
19
+
20
+ private
21
+
22
+ def editor_insert_printable_with_pairs(text)
23
+ text = text.to_s
24
+ return false unless current_editor_auto_close_pairs?
25
+ return false unless text.length == 1
26
+
27
+ if @editor_state.selection_active? && AUTO_CLOSE_PAIRS.key?(text)
28
+ editor_insert_auto_close_pair(text, AUTO_CLOSE_PAIRS.fetch(text))
29
+ return true
30
+ end
31
+
32
+ if AUTO_CLOSE_CLOSERS.include?(text) && editor_next_character == text
33
+ @editor_state.move_right
34
+ return true
35
+ end
36
+
37
+ if AUTO_CLOSE_PAIRS.key?(text)
38
+ return false if editor_quote_pair?(text) && editor_quote_inside_word?
39
+
40
+ editor_insert_auto_close_pair(text, AUTO_CLOSE_PAIRS.fetch(text))
41
+ return true
42
+ end
43
+
44
+ false
45
+ end
46
+
47
+ def editor_delete_auto_close_pair_before_cursor
48
+ return false unless current_editor_auto_close_pairs?
49
+
50
+ opener = editor_previous_character
51
+ closer = editor_next_character
52
+ return false unless opener && closer
53
+ return false unless AUTO_CLOSE_PAIRS[opener] == closer
54
+
55
+ @editor_state.replace_range(@editor_state.cursor - 1, @editor_state.cursor + 1, "")
56
+ true
57
+ end
58
+
59
+ def current_editor_auto_close_pairs?
60
+ return @editor_auto_close_pairs_source.call != false if @editor_auto_close_pairs_source.respond_to?(:call)
61
+
62
+ @editor_auto_close_pairs != false
63
+ rescue StandardError
64
+ @editor_auto_close_pairs != false
65
+ end
66
+
67
+ def editor_insert_auto_close_pair(opener, closer)
68
+ range = editor_auto_close_pair_range(opener)
69
+ if range
70
+ selected = @editor_state.buffer[range[0]...range[1]].to_s
71
+ @editor_state.replace_range(range[0], range[1], "#{opener}#{selected}#{closer}")
72
+ @editor_state.cursor = range[1] + opener.length + closer.length
73
+ @editor_state.clear_selection
74
+ return
75
+ end
76
+
77
+ @editor_state.insert("#{opener}#{closer}")
78
+ @editor_state.move_left
79
+ end
80
+
81
+ def editor_auto_close_pair_range(opener)
82
+ range = @editor_state.selection_range
83
+ return nil unless range
84
+ return range unless editor_quote_pair?(opener)
85
+ return range if @editor_state.vibe?
86
+
87
+ editor_quote_selection_range(range)
88
+ end
89
+
90
+ def editor_quote_selection_range(range)
91
+ start_index, end_index = range
92
+ return range unless editor_word_character?(@editor_state.buffer[(end_index - 1)...end_index])
93
+ return range unless editor_word_character?(@editor_state.buffer[end_index...(end_index + 1)])
94
+ return range if editor_word_character?(@editor_state.buffer[(start_index - 1)...start_index])
95
+ return range if editor_word_character?(@editor_state.buffer[(end_index + 1)...(end_index + 2)])
96
+
97
+ [start_index, end_index + 1]
98
+ end
99
+
100
+ def editor_quote_pair?(text)
101
+ AUTO_CLOSE_QUOTES.include?(text)
102
+ end
103
+
104
+ def editor_quote_inside_word?
105
+ editor_word_character?(editor_previous_character) || editor_word_character?(editor_next_character)
106
+ end
107
+
108
+ def editor_previous_character
109
+ return nil if @editor_state.cursor.zero?
110
+
111
+ @editor_state.buffer[(@editor_state.cursor - 1)...@editor_state.cursor]
112
+ end
113
+
114
+ def editor_next_character
115
+ @editor_state.buffer[@editor_state.cursor...(@editor_state.cursor + 1)]
116
+ end
117
+
118
+ def editor_word_character?(character)
119
+ character.to_s.match?(WORD_CHARACTER)
120
+ end
121
+ end
122
+ end
123
+ end