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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +89 -3
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +34 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +52 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +58 -23
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +102 -13
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +83 -0
- 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 +74 -3
- data/doc/releasing.md +45 -8
- data/doc/rpc.md +77 -15
- data/doc/session-management.md +254 -0
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +60 -15
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +144 -0
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +41 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +111 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +262 -13
- data/lib/kward/cli/settings.rb +216 -37
- data/lib/kward/cli/slash_commands.rb +439 -8
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +171 -26
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +125 -5
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +59 -22
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -16
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +108 -1
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +124 -83
- data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
- 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 +416 -43
- data/lib/kward/prompt_interface/layout.rb +2 -2
- 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/prompt_renderer.rb +32 -13
- data/lib/kward/prompt_interface/question_prompt.rb +122 -82
- data/lib/kward/prompt_interface/runtime_state.rb +49 -1
- data/lib/kward/prompt_interface/screen.rb +17 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +307 -35
- data/lib/kward/prompts/commands.rb +7 -1
- data/lib/kward/prompts.rb +4 -2
- data/lib/kward/rpc/server.rb +45 -11
- data/lib/kward/rpc/session_manager.rb +52 -53
- 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 +67 -4
- 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/telemetry/logger.rb +5 -3
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- 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 +92 -15
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +12 -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 +154 -12
- data/templates/default/fulldoc/html/css/kward.css +362 -42
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +161 -2
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +102 -0
- data/templates/default/layout/html/layout.erb +43 -10
- data/templates/default/layout/html/setup.rb +39 -38
- metadata +65 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- 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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
74
|
-
|
|
69
|
+
@composer.add_attachment(attachment)
|
|
70
|
+
end
|
|
75
71
|
|
|
76
72
|
def delete_before_cursor
|
|
77
|
-
|
|
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
|
-
|
|
85
|
+
end
|
|
86
86
|
|
|
87
87
|
def remove_last_attachment
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
end
|
|
103
111
|
|
|
104
112
|
def move_cursor_left
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
@composer.move_cursor_left
|
|
114
|
+
end
|
|
107
115
|
|
|
108
116
|
def move_cursor_right
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
@composer.move_cursor_right
|
|
118
|
+
end
|
|
111
119
|
|
|
112
120
|
def move_to_start_of_line
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
@composer.move_to_start_of_line
|
|
122
|
+
end
|
|
115
123
|
|
|
116
124
|
def move_to_end_of_line
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
@composer.move_to_end_of_line
|
|
126
|
+
end
|
|
119
127
|
|
|
120
128
|
def move_to_previous_word
|
|
121
|
-
|
|
122
|
-
|
|
129
|
+
@composer.move_to_previous_word
|
|
130
|
+
end
|
|
123
131
|
|
|
124
132
|
def move_to_next_word
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
154
|
-
|
|
168
|
+
@file_overlay_dismissed_token = nil
|
|
169
|
+
@composer.kill_line_after_cursor
|
|
170
|
+
end
|
|
155
171
|
|
|
156
|
-
def
|
|
157
|
-
|
|
172
|
+
def yank_kill_buffer
|
|
173
|
+
@composer.yank_kill_buffer
|
|
174
|
+
end
|
|
158
175
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
176
|
+
def load_history(values)
|
|
177
|
+
@composer.load_history(values)
|
|
178
|
+
end
|
|
162
179
|
|
|
163
|
-
def
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
168
|
-
|
|
186
|
+
def recall_previous_history
|
|
187
|
+
@composer.recall_previous_history
|
|
169
188
|
end
|
|
170
189
|
|
|
171
|
-
def
|
|
172
|
-
|
|
190
|
+
def recall_next_history
|
|
191
|
+
@composer.recall_next_history
|
|
173
192
|
end
|
|
174
193
|
|
|
175
|
-
def
|
|
176
|
-
@composer.
|
|
194
|
+
def start_history_search
|
|
195
|
+
@composer.start_history_search
|
|
177
196
|
end
|
|
178
197
|
|
|
179
|
-
def
|
|
180
|
-
|
|
181
|
-
|
|
198
|
+
def history_search_active?
|
|
199
|
+
@composer.history_search_active?
|
|
200
|
+
end
|
|
182
201
|
|
|
183
|
-
def
|
|
184
|
-
|
|
185
|
-
|
|
202
|
+
def update_history_search_query(value)
|
|
203
|
+
@composer.update_history_search_query(value)
|
|
204
|
+
end
|
|
186
205
|
|
|
187
|
-
def
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -85,7 +91,7 @@ module Kward
|
|
|
85
91
|
end
|
|
86
92
|
|
|
87
93
|
def composer_title
|
|
88
|
-
label =
|
|
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
|
|
101
|
-
|
|
102
|
-
end
|
|
106
|
+
def composer_title_label
|
|
107
|
+
return "Search" if @select_state && select_search_active?
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return nil if text.empty?
|
|
109
|
+
@prompt_label.delete_suffix(">")
|
|
110
|
+
end
|
|
107
111
|
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|