kward 0.70.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -26,14 +26,6 @@ module Kward
26
26
  @composer.attachments
27
27
  end
28
28
 
29
- def composer_kill_buffer
30
- @composer.kill_buffer
31
- end
32
-
33
- def composer_kill_buffer=(value)
34
- @composer.kill_buffer = value.to_s
35
- end
36
-
37
29
  def insert_key(key)
38
30
  return unless key.is_a?(String) && key.length == 1 && key.match?(/[[:print:]]/)
39
31
 
@@ -44,10 +36,14 @@ module Kward
44
36
  return if string.empty?
45
37
 
46
38
  reset_slash_selection
39
+ reset_file_selection
47
40
  reset_history_navigation
48
41
  @slash_overlay_dismissed_input = nil
49
- @composer.insert_string(string)
50
- end
42
+ @file_overlay_dismissed_token = nil
43
+ @file_open_dismissed_token = nil
44
+ @file_editor_open_status = nil
45
+ @composer.insert_string(string)
46
+ end
51
47
 
52
48
  def insert_paste(string)
53
49
  parsed = parse_attachments(string)
@@ -70,60 +66,72 @@ module Kward
70
66
  end
71
67
 
72
68
  def add_attachment(attachment)
73
- @composer.add_attachment(attachment)
74
- end
69
+ @composer.add_attachment(attachment)
70
+ end
75
71
 
76
72
  def delete_before_cursor
77
- if @composer.cursor.zero?
73
+ if @composer.cursor.zero?
78
74
  remove_last_attachment
79
75
  return
80
76
  end
81
77
 
82
78
  reset_slash_selection
79
+ reset_file_selection
83
80
  reset_history_navigation
81
+ @file_overlay_dismissed_token = nil
82
+ @file_open_dismissed_token = nil
83
+ @file_editor_open_status = nil
84
84
  @composer.delete_before_cursor
85
- end
85
+ end
86
86
 
87
87
  def remove_last_attachment
88
- return unless @composer.remove_last_attachment
88
+ return unless @composer.remove_last_attachment
89
89
 
90
90
  reset_slash_selection
91
+ reset_file_selection
91
92
  reset_history_navigation
92
93
  @slash_overlay_dismissed_input = nil
93
- end
94
+ @file_overlay_dismissed_token = nil
95
+ @file_open_dismissed_token = nil
96
+ @file_editor_open_status = nil
97
+ end
94
98
 
95
99
  def delete_at_cursor
96
- return unless @composer.cursor < @composer.input.length
100
+ return unless @composer.cursor < @composer.input.length
97
101
 
98
102
  reset_slash_selection
103
+ reset_file_selection
99
104
  reset_history_navigation
100
105
  @slash_overlay_dismissed_input = nil
106
+ @file_overlay_dismissed_token = nil
107
+ @file_open_dismissed_token = nil
108
+ @file_editor_open_status = nil
101
109
  @composer.delete_at_cursor
102
- end
110
+ end
103
111
 
104
112
  def move_cursor_left
105
- @composer.move_cursor_left
106
- end
113
+ @composer.move_cursor_left
114
+ end
107
115
 
108
116
  def move_cursor_right
109
- @composer.move_cursor_right
110
- end
117
+ @composer.move_cursor_right
118
+ end
111
119
 
112
120
  def move_to_start_of_line
113
- @composer.move_to_start_of_line
114
- end
121
+ @composer.move_to_start_of_line
122
+ end
115
123
 
116
124
  def move_to_end_of_line
117
- @composer.move_to_end_of_line
118
- end
125
+ @composer.move_to_end_of_line
126
+ end
119
127
 
120
128
  def move_to_previous_word
121
- @composer.move_to_previous_word
122
- end
129
+ @composer.move_to_previous_word
130
+ end
123
131
 
124
132
  def move_to_next_word
125
- @composer.move_to_next_word
126
- end
133
+ @composer.move_to_next_word
134
+ end
127
135
 
128
136
  def delete_at_cursor_or_exit
129
137
  composer_input.empty? ? exit_input : delete_at_cursor
@@ -131,66 +139,93 @@ module Kward
131
139
 
132
140
  def delete_word_before_cursor
133
141
  reset_slash_selection
142
+ reset_file_selection
134
143
  reset_history_navigation
135
- @composer.delete_word_before_cursor
136
- end
144
+ @file_overlay_dismissed_token = nil
145
+ @composer.delete_word_before_cursor
146
+ end
137
147
 
138
148
  def delete_word_after_cursor
139
149
  reset_slash_selection
150
+ reset_file_selection
140
151
  reset_history_navigation
141
- @composer.delete_word_after_cursor
142
- end
152
+ @file_overlay_dismissed_token = nil
153
+ @composer.delete_word_after_cursor
154
+ end
143
155
 
144
156
  def kill_line_before_cursor
145
157
  reset_slash_selection
158
+ reset_file_selection
146
159
  reset_history_navigation
147
- @composer.kill_line_before_cursor
148
- end
160
+ @file_overlay_dismissed_token = nil
161
+ @composer.kill_line_before_cursor
162
+ end
149
163
 
150
164
  def kill_line_after_cursor
151
165
  reset_slash_selection
166
+ reset_file_selection
152
167
  reset_history_navigation
153
- @composer.kill_line_after_cursor
154
- end
168
+ @file_overlay_dismissed_token = nil
169
+ @composer.kill_line_after_cursor
170
+ end
155
171
 
156
- def kill_range(start_index, end_index)
157
- return unless @composer.kill_range(start_index, end_index)
172
+ def yank_kill_buffer
173
+ @composer.yank_kill_buffer
174
+ end
158
175
 
159
- reset_slash_selection
160
- reset_history_navigation
161
- end
176
+ def load_history(values)
177
+ @composer.load_history(values)
178
+ end
162
179
 
163
- def yank_kill_buffer
164
- @composer.yank_kill_buffer
165
- end
180
+ def add_history(value)
181
+ added = @composer.add_history(value)
182
+ @prompt_history&.append(value) if added
183
+ added
184
+ end
166
185
 
167
- def previous_word_boundary(index)
168
- @composer.previous_word_boundary(index)
186
+ def recall_previous_history
187
+ @composer.recall_previous_history
169
188
  end
170
189
 
171
- def next_word_boundary(index)
172
- @composer.next_word_boundary(index)
190
+ def recall_next_history
191
+ @composer.recall_next_history
173
192
  end
174
193
 
175
- def word_separator?(char)
176
- @composer.word_separator?(char)
194
+ def start_history_search
195
+ @composer.start_history_search
177
196
  end
178
197
 
179
- def add_history(value)
180
- @composer.add_history(value)
181
- end
198
+ def history_search_active?
199
+ @composer.history_search_active?
200
+ end
182
201
 
183
- def recall_previous_history
184
- @composer.recall_previous_history
185
- end
202
+ def update_history_search_query(value)
203
+ @composer.update_history_search_query(value)
204
+ end
186
205
 
187
- def recall_next_history
188
- @composer.recall_next_history
189
- end
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
190
225
 
191
226
  def replace_input(value)
192
227
  @composer.replace_input(value)
193
- end
228
+ end
194
229
 
195
230
  def prefill_input(value)
196
231
  @mutex.synchronize do
@@ -199,19 +234,41 @@ module Kward
199
234
  end
200
235
 
201
236
  def reset_history_navigation
202
- @composer.reset_history_navigation
203
- end
237
+ @composer.reset_history_navigation
238
+ end
204
239
 
240
+ def reset_history_search
241
+ @composer.reset_history_search
242
+ end
243
+
244
+ def prepare_modal_input_locked(label, clear_attachments: false)
245
+ @prompt_label = label.to_s
246
+ self.composer_input = ""
247
+ self.composer_cursor = 0
248
+ @composer.clear_attachments if clear_attachments
249
+ @pending_keys.clear
250
+ @asking = true
251
+ @busy = false
252
+ @queued_count = 0
253
+ reset_history_navigation
254
+ end
205
255
 
206
256
  def submit_input
207
257
  value = submitted_input
208
258
  add_history(composer_input)
259
+ clear_finished_input_locked(reset_history: true)
260
+ @output_io.flush
261
+ value
262
+ end
263
+
264
+ def clear_finished_input_locked(reset_history: false)
209
265
  if @busy
210
266
  clear_prompt_for_output_locked
211
267
  self.composer_input = ""
212
268
  self.composer_cursor = 0
213
269
  @composer.clear_attachments
214
- reset_history_navigation
270
+ reset_history_navigation if reset_history
271
+ reset_history_search if reset_history
215
272
  @asking = true
216
273
  render_prompt_after_output_locked
217
274
  else
@@ -219,12 +276,11 @@ module Kward
219
276
  self.composer_input = ""
220
277
  self.composer_cursor = 0
221
278
  @composer.clear_attachments
279
+ reset_history_search if reset_history
222
280
  @asking = false
223
281
  @rendered_rows = 0
224
282
  @cursor_rendered_row = 0
225
283
  end
226
- @output_io.flush
227
- value
228
284
  end
229
285
 
230
286
  def submitted_input
@@ -237,22 +293,7 @@ module Kward
237
293
  end
238
294
 
239
295
  def exit_input
240
- if @busy
241
- clear_prompt_for_output_locked
242
- self.composer_input = ""
243
- self.composer_cursor = 0
244
- @composer.clear_attachments
245
- @asking = true
246
- render_prompt_after_output_locked
247
- else
248
- clear_prompt_locked
249
- self.composer_input = ""
250
- self.composer_cursor = 0
251
- @composer.clear_attachments
252
- @asking = false
253
- @rendered_rows = 0
254
- @cursor_rendered_row = 0
255
- end
296
+ clear_finished_input_locked
256
297
  @output_io.flush
257
298
  EXIT_INPUT
258
299
  end
@@ -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
@@ -85,7 +91,7 @@ module Kward
85
91
  end
86
92
 
87
93
  def composer_title
88
- label = @prompt_label.delete_suffix(">")
94
+ label = composer_title_label
89
95
  if @busy && @queued_count.positive?
90
96
  status_composer_text(busy_title("#{label} · #{@queued_count} queued"))
91
97
  elsif @busy && @steered_count.to_i.positive?
@@ -97,15 +103,14 @@ module Kward
97
103
  end
98
104
  end
99
105
 
100
- def busy_title(text)
101
- @busy_help ? "#{text} · #{BUSY_HELP_TEXT}" : text
102
- end
106
+ def composer_title_label
107
+ return "Search" if @select_state && select_search_active?
103
108
 
104
- def composer_status_text
105
- text = @composer_status&.call.to_s
106
- return nil if text.empty?
109
+ @prompt_label.delete_suffix(">")
110
+ end
107
111
 
108
- status_composer_text(text)
112
+ def busy_title(text)
113
+ @busy_help ? "#{text} · #{BUSY_HELP_TEXT}" : text
109
114
  end
110
115
 
111
116
  def status_composer_text(text)
@@ -116,8 +121,106 @@ module Kward
116
121
  colored("╰#{"─" * [width - 2, 0].max}╯", :primary_green)
117
122
  end
118
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
+
119
221
  def box_content_row(row, content_width)
120
- "#{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)}"
121
224
  end
122
225
 
123
226
  def footer_row(content_width, text = footer_text)
@@ -148,7 +251,7 @@ module Kward
148
251
  []
149
252
  end
150
253
 
151
- 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)
152
255
  input_cap = [COMPOSER_MAX_INPUT_ROWS - attachment_count, 1].max
153
256
  [[input_cap, height - 3 - overlay_count - footer_count - attachment_count].min, 1].max
154
257
  end
@@ -163,8 +266,7 @@ module Kward
163
266
  end
164
267
 
165
268
  def cursor_logical_position
166
- before_cursor = composer_input[0...composer_cursor]
167
- [before_cursor.count("\n"), (before_cursor.split("\n", -1).last || "").length]
269
+ @composer.cursor_logical_position
168
270
  end
169
271
 
170
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