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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -1
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +74 -4
- data/doc/session-management.md +35 -1
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +53 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +218 -9
- data/lib/kward/cli/slash_commands.rb +415 -2
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +158 -26
- data/lib/kward/config_files.rb +123 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +299 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +387 -35
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/question_prompt.rb +98 -50
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +16 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +286 -8
- data/lib/kward/prompts/commands.rb +5 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/server.rb +42 -3
- data/lib/kward/rpc/session_manager.rb +35 -47
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +44 -0
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +7 -0
- data/lib/kward/workspace.rb +110 -24
- data/templates/default/fulldoc/html/css/kward.css +107 -36
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +4 -2
- data/templates/default/layout/html/setup.rb +6 -0
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|