kward 0.81.0 → 0.83.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 +57 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/Rakefile +44 -4
- data/doc/agent-tools.md +1 -0
- data/doc/composer.md +2 -2
- data/doc/configuration.md +18 -3
- data/doc/editor.md +20 -10
- data/doc/files.md +12 -5
- data/doc/permissions.md +1 -0
- data/doc/releasing.md +79 -38
- data/doc/rpc.md +2 -2
- data/doc/sandboxing.md +5 -1
- data/doc/security.md +2 -2
- data/doc/shell.md +42 -18
- data/doc/tabs.md +3 -0
- data/doc/usage.md +3 -2
- data/kward.gemspec +1 -1
- data/lib/kward/adaptive_pty_output_sink.rb +183 -0
- data/lib/kward/agent.rb +1 -0
- data/lib/kward/auth/anthropic_oauth.rb +7 -7
- data/lib/kward/cli/git.rb +5 -1
- data/lib/kward/cli/interactive_turn.rb +101 -19
- data/lib/kward/cli/rendering.rb +14 -2
- data/lib/kward/cli/runtime_helpers.rb +252 -46
- data/lib/kward/cli/settings.rb +5 -13
- data/lib/kward/cli/slash_commands.rb +12 -1
- data/lib/kward/cli/tabs.rb +71 -9
- data/lib/kward/cli.rb +12 -0
- data/lib/kward/compaction/token_estimator.rb +12 -6
- data/lib/kward/config_files.rb +17 -0
- data/lib/kward/editor_prompt.rb +46 -0
- data/lib/kward/editor_prompt_session.rb +28 -0
- data/lib/kward/ekwsh.rb +70 -10
- data/lib/kward/image_attachments.rb +98 -15
- data/lib/kward/interactive_pty_runner.rb +25 -19
- data/lib/kward/model/client.rb +1 -17
- data/lib/kward/model/model_info.rb +3 -2
- data/lib/kward/model/payloads.rb +0 -2
- data/lib/kward/persistent_shell_session.rb +750 -0
- data/lib/kward/project_files.rb +18 -5
- data/lib/kward/prompt_interface/composer_controller.rb +3 -3
- data/lib/kward/prompt_interface/composer_renderer.rb +28 -1
- data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
- data/lib/kward/prompt_interface/editor/controller.rb +75 -6
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
- data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +12 -6
- data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -0
- data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
- data/lib/kward/prompt_interface/file_overlay.rb +21 -7
- data/lib/kward/prompt_interface/git_prompt.rb +1 -1
- data/lib/kward/prompt_interface/key_handler.rb +30 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
- data/lib/kward/prompt_interface/project_browser.rb +192 -8
- data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/runtime_state.rb +5 -1
- data/lib/kward/prompt_interface/screen.rb +40 -20
- data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
- data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
- data/lib/kward/prompt_interface.rb +159 -32
- data/lib/kward/prompts/commands.rb +1 -0
- data/lib/kward/pty_output_sink.rb +45 -0
- data/lib/kward/pty_transcript_normalizer.rb +93 -0
- data/lib/kward/rpc/auth_manager.rb +1 -1
- data/lib/kward/rpc/server.rb +2 -1
- data/lib/kward/session_catalog.rb +87 -0
- data/lib/kward/session_store.rb +94 -5
- data/lib/kward/shell_prompt.rb +50 -0
- data/lib/kward/shell_prompt_session.rb +58 -0
- data/lib/kward/terminal_image_support.rb +116 -0
- data/lib/kward/terminal_keys.rb +1 -0
- data/lib/kward/terminal_sequences.rb +43 -0
- data/lib/kward/tools/prepare_shell_command.rb +28 -0
- data/lib/kward/tools/registry.rb +63 -5
- data/lib/kward/tools/replace_editor_buffer.rb +30 -0
- data/lib/kward/tools/run_shell_command.rb +24 -6
- data/lib/kward/version.rb +1 -1
- data/templates/default/layout/html/layout.erb +1 -1
- metadata +15 -4
- data/.github/workflows/ci.yml +0 -48
- data/.github/workflows/pages.yml +0 -48
data/lib/kward/project_files.rb
CHANGED
|
@@ -8,21 +8,34 @@ module Kward
|
|
|
8
8
|
module ProjectFiles
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
|
-
def list(root: Dir.pwd)
|
|
12
|
-
paths = git_paths(root)
|
|
13
|
-
paths = scanned_paths(root) if paths.empty?
|
|
11
|
+
def list(root: Dir.pwd, include_ignored: false)
|
|
12
|
+
paths = git_paths(root, include_ignored: include_ignored)
|
|
13
|
+
paths = scanned_paths(root) if paths.empty? && !git_repository?(root)
|
|
14
14
|
paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def git_paths(root)
|
|
17
|
+
def git_paths(root, include_ignored: false)
|
|
18
18
|
output, status = Open3.capture2("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
|
|
19
19
|
return [] unless status.success?
|
|
20
20
|
|
|
21
|
-
output.lines.map(&:chomp).reject(&:empty?)
|
|
21
|
+
paths = output.lines.map(&:chomp).reject(&:empty?)
|
|
22
|
+
return paths unless include_ignored
|
|
23
|
+
|
|
24
|
+
ignored_output, ignored_status = Open3.capture2("git", "ls-files", "--others", "--ignored", "--exclude-standard", chdir: root)
|
|
25
|
+
return paths unless ignored_status.success?
|
|
26
|
+
|
|
27
|
+
paths + ignored_output.lines.map(&:chomp).reject(&:empty?)
|
|
22
28
|
rescue StandardError
|
|
23
29
|
[]
|
|
24
30
|
end
|
|
25
31
|
|
|
32
|
+
def git_repository?(root)
|
|
33
|
+
_output, status = Open3.capture2e("git", "rev-parse", "--is-inside-work-tree", chdir: root)
|
|
34
|
+
status.success?
|
|
35
|
+
rescue StandardError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
26
39
|
def scanned_paths(root)
|
|
27
40
|
root_path = Pathname.new(root)
|
|
28
41
|
paths = []
|
|
@@ -257,12 +257,12 @@ module Kward
|
|
|
257
257
|
value = submitted_input
|
|
258
258
|
add_history(composer_input)
|
|
259
259
|
if !@busy && value.strip.empty?
|
|
260
|
-
|
|
260
|
+
flush_output_locked
|
|
261
261
|
return value
|
|
262
262
|
end
|
|
263
263
|
|
|
264
264
|
clear_submitted_input_locked
|
|
265
|
-
|
|
265
|
+
flush_output_locked
|
|
266
266
|
value
|
|
267
267
|
end
|
|
268
268
|
|
|
@@ -306,7 +306,7 @@ module Kward
|
|
|
306
306
|
|
|
307
307
|
def exit_input
|
|
308
308
|
clear_finished_input_locked
|
|
309
|
-
|
|
309
|
+
flush_output_locked
|
|
310
310
|
EXIT_INPUT
|
|
311
311
|
end
|
|
312
312
|
|
|
@@ -8,7 +8,7 @@ module Kward
|
|
|
8
8
|
|
|
9
9
|
def composer_layout(width, height = screen_height)
|
|
10
10
|
return interactive_layout(width, height) if interactive_active_locked?
|
|
11
|
-
return editor_layout(width, height) if
|
|
11
|
+
return editor_layout(width, height) if editor_visible?
|
|
12
12
|
return compact_composer_layout(width) if height < 4
|
|
13
13
|
return question_composer_layout(width, height) if @question_state
|
|
14
14
|
|
|
@@ -265,6 +265,33 @@ module Kward
|
|
|
265
265
|
@composer.cursor_logical_position
|
|
266
266
|
end
|
|
267
267
|
|
|
268
|
+
def image_viewer_preview_rows(height, width: screen_width)
|
|
269
|
+
available_rows = max_project_browser_rows(height, width: width)
|
|
270
|
+
desired_rows = [(IMAGE_VIEWER_MAX_ROWS * image_viewer_zoom).round, 1].max
|
|
271
|
+
[available_rows, desired_rows].min
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def image_viewer_rows(width, height: screen_height)
|
|
275
|
+
content_width = [width - 4, 1].max
|
|
276
|
+
preview_rows = image_viewer_preview_rows(height, width: width)
|
|
277
|
+
title = visible_truncate(" Image: #{@image_viewer_state[:display_path]} ", content_width)
|
|
278
|
+
rows = [
|
|
279
|
+
colored("╭", :primary_green) + title + colored("─" * [width - ANSI.strip(title).length - 2, 0].max, :primary_green) + colored("╮", :primary_green),
|
|
280
|
+
image_viewer_graphic_row(width, image_viewer_sequence(width, height))
|
|
281
|
+
]
|
|
282
|
+
rows.concat(Array.new(preview_rows - 1) { box_content_row("", content_width) })
|
|
283
|
+
zoom = (image_viewer_zoom * 100).round
|
|
284
|
+
rows << footer_row(content_width, "Read-only image preview · #{zoom}% · +/- zoom · Esc/Q close")
|
|
285
|
+
rows << bottom_border(width)
|
|
286
|
+
rows
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def image_viewer_graphic_row(width, sequence)
|
|
290
|
+
left_border = "#{colored("│", :primary_green)} "
|
|
291
|
+
right_border = "#{TerminalSequences.move_to_column(width)}#{colored("│", :primary_green)}"
|
|
292
|
+
"#{TTY::Cursor.clear_line}#{left_border}#{sequence}#{right_border}"
|
|
293
|
+
end
|
|
294
|
+
|
|
268
295
|
end
|
|
269
296
|
end
|
|
270
297
|
end
|
|
@@ -83,9 +83,12 @@ module Kward
|
|
|
83
83
|
|
|
84
84
|
def editor_insert_tab
|
|
85
85
|
if @editor_state.multi_cursor? || @editor_state.selection_ranges.any?
|
|
86
|
+
reset_editor_word_completion
|
|
86
87
|
return @editor_state.replace_selections(editor_indent_unit)
|
|
87
88
|
end
|
|
88
89
|
|
|
90
|
+
return true if editor_complete_buffer_word
|
|
91
|
+
|
|
89
92
|
if current_editor_auto_indent? && editor_cursor_in_leading_indent?
|
|
90
93
|
return editor_smart_tab_forward
|
|
91
94
|
end
|
|
@@ -13,6 +13,10 @@ module Kward
|
|
|
13
13
|
!@editor_state.nil?
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def editor_visible?
|
|
17
|
+
editor_active?
|
|
18
|
+
end
|
|
19
|
+
|
|
16
20
|
def open_selected_file_in_editor(fallback_to_typed_path: false)
|
|
17
21
|
path = selected_file_open_path
|
|
18
22
|
if path
|
|
@@ -58,6 +62,7 @@ module Kward
|
|
|
58
62
|
language: language
|
|
59
63
|
)
|
|
60
64
|
@editor_state.status = scratchpad_status_text(language)
|
|
65
|
+
@editor_agent_suspended = false
|
|
61
66
|
@prompt_label = "Edit>"
|
|
62
67
|
self.composer_input = ""
|
|
63
68
|
self.composer_cursor = 0
|
|
@@ -95,6 +100,7 @@ module Kward
|
|
|
95
100
|
end
|
|
96
101
|
|
|
97
102
|
@editor_state = EditorState.new(path: full_path, content: File.exist?(full_path) ? File.read(full_path) : "", new_file: !File.exist?(full_path), editor_mode: current_editor_mode)
|
|
103
|
+
@editor_agent_suspended = false
|
|
98
104
|
@prompt_label = "Edit>"
|
|
99
105
|
self.composer_input = ""
|
|
100
106
|
self.composer_cursor = 0
|
|
@@ -115,6 +121,7 @@ module Kward
|
|
|
115
121
|
diff_view_mode = current_diff_view_mode
|
|
116
122
|
viewer_content = diff_view_mode == DiffViewMode::SIDE_BY_SIDE ? side_by_side_diff_content(content.to_s) : content.to_s
|
|
117
123
|
@editor_state = EditorState.new(path: path.to_s, content: viewer_content, new_file: true, editor_mode: current_editor_mode, readonly: true, diff_view: diff_view_mode)
|
|
124
|
+
@editor_agent_suspended = false
|
|
118
125
|
@prompt_label = "Diff>"
|
|
119
126
|
self.composer_input = ""
|
|
120
127
|
self.composer_cursor = 0
|
|
@@ -243,6 +250,7 @@ module Kward
|
|
|
243
250
|
@editor_save_as_buffer = ""
|
|
244
251
|
@editor_save_callback = nil
|
|
245
252
|
@editor_state = nil
|
|
253
|
+
@editor_agent_suspended = false
|
|
246
254
|
@prompt_label = "You>"
|
|
247
255
|
self.composer_input = ""
|
|
248
256
|
self.composer_cursor = 0
|
|
@@ -252,6 +260,9 @@ module Kward
|
|
|
252
260
|
|
|
253
261
|
def handle_editor_key(key)
|
|
254
262
|
return if key.nil?
|
|
263
|
+
return handle_editor_prompt_key(key) if @editor_prompt_active
|
|
264
|
+
|
|
265
|
+
reset_editor_word_completion unless editor_word_completion_tab_key?(key) && !editor_search_active?
|
|
255
266
|
mouse_result = handle_editor_mouse_key(key)
|
|
256
267
|
return mouse_result unless mouse_result == false
|
|
257
268
|
return handle_editor_save_as_key(key) if @editor_save_as_active
|
|
@@ -312,6 +323,64 @@ module Kward
|
|
|
312
323
|
end
|
|
313
324
|
end
|
|
314
325
|
|
|
326
|
+
def editor_prompt_action(instruction)
|
|
327
|
+
{
|
|
328
|
+
editor_prompt: {
|
|
329
|
+
instruction: instruction.to_s.strip,
|
|
330
|
+
display_input: ":prompt #{instruction.to_s.strip}"
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def begin_editor_prompt
|
|
336
|
+
@editor_prompt_active = true
|
|
337
|
+
@editor_prompt_input = ""
|
|
338
|
+
@editor_state.status = "Prompt: "
|
|
339
|
+
true
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def handle_editor_prompt_key(key)
|
|
343
|
+
sequence = parse_csi_u_key(key)
|
|
344
|
+
if sequence
|
|
345
|
+
queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
|
|
346
|
+
return handle_editor_prompt_key("\r") if sequence[:code] == 13
|
|
347
|
+
return handle_editor_prompt_key("\e") if sequence[:code] == 27
|
|
348
|
+
return handle_editor_prompt_key("\b") if [8, 127].include?(sequence[:code])
|
|
349
|
+
|
|
350
|
+
text = csi_u_printable_text(sequence)
|
|
351
|
+
return handle_editor_prompt_key(text) if text
|
|
352
|
+
|
|
353
|
+
return true
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
case key
|
|
357
|
+
when "\e", TerminalKeys::CTRL_C, :escape
|
|
358
|
+
@editor_prompt_active = false
|
|
359
|
+
@editor_prompt_input = ""
|
|
360
|
+
@editor_state.status = EditorStatusText.default(readonly: @editor_state.readonly?, editor_mode: @editor_state.editor_mode)
|
|
361
|
+
true
|
|
362
|
+
when "\b", "\x7F"
|
|
363
|
+
@editor_prompt_input = @editor_prompt_input[0...-1]
|
|
364
|
+
@editor_state.status = "Prompt: #{@editor_prompt_input}"
|
|
365
|
+
when "\n", "\r"
|
|
366
|
+
instruction = @editor_prompt_input.strip
|
|
367
|
+
@editor_prompt_active = false
|
|
368
|
+
@editor_prompt_input = ""
|
|
369
|
+
if instruction.empty?
|
|
370
|
+
@editor_state.status = "Prompt instruction required"
|
|
371
|
+
true
|
|
372
|
+
else
|
|
373
|
+
editor_prompt_action(instruction)
|
|
374
|
+
end
|
|
375
|
+
else
|
|
376
|
+
if printable_key?(key)
|
|
377
|
+
@editor_prompt_input += key
|
|
378
|
+
@editor_state.status = "Prompt: #{@editor_prompt_input}"
|
|
379
|
+
end
|
|
380
|
+
true
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
315
384
|
def handle_editor_csi_u_key(key)
|
|
316
385
|
sequence = parse_csi_u_key(key)
|
|
317
386
|
return false unless sequence
|
|
@@ -992,8 +1061,8 @@ module Kward
|
|
|
992
1061
|
return false if text.empty?
|
|
993
1062
|
|
|
994
1063
|
@editor_state.push_kill(text)
|
|
995
|
-
|
|
996
|
-
|
|
1064
|
+
print_output_locked(TerminalSequences.osc52(text))
|
|
1065
|
+
flush_output_locked if @output_io.respond_to?(:flush)
|
|
997
1066
|
@editor_state.clear_selection
|
|
998
1067
|
@editor_state.status = "Copied selection"
|
|
999
1068
|
true
|
|
@@ -1103,16 +1172,16 @@ module Kward
|
|
|
1103
1172
|
def enable_editor_mouse_reporting
|
|
1104
1173
|
return if @editor_mouse_reporting_enabled
|
|
1105
1174
|
|
|
1106
|
-
|
|
1107
|
-
|
|
1175
|
+
print_output_locked(TerminalSequences::MOUSE_REPORTING_ENABLE)
|
|
1176
|
+
flush_output_locked if @output_io.respond_to?(:flush)
|
|
1108
1177
|
@editor_mouse_reporting_enabled = true
|
|
1109
1178
|
end
|
|
1110
1179
|
|
|
1111
1180
|
def disable_editor_mouse_reporting(force: false)
|
|
1112
1181
|
return unless force || @editor_mouse_reporting_enabled
|
|
1113
1182
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1183
|
+
print_output_locked(TerminalSequences::MOUSE_REPORTING_DISABLE)
|
|
1184
|
+
flush_output_locked if @output_io.respond_to?(:flush)
|
|
1116
1185
|
@editor_mouse_reporting_enabled = false
|
|
1117
1186
|
end
|
|
1118
1187
|
|
|
@@ -240,8 +240,8 @@ module Kward
|
|
|
240
240
|
end
|
|
241
241
|
|
|
242
242
|
@editor_state.copy_for_kill_ring(range[0], range[1])
|
|
243
|
-
|
|
244
|
-
|
|
243
|
+
print_output_locked(TerminalSequences.osc52(@editor_state.kill_buffer))
|
|
244
|
+
flush_output_locked if @output_io.respond_to?(:flush)
|
|
245
245
|
@editor_state.clear_selection
|
|
246
246
|
@editor_state.status = "Copied region"
|
|
247
247
|
true
|
|
@@ -198,6 +198,8 @@ module Kward
|
|
|
198
198
|
|
|
199
199
|
def handle_modern_key_binding(key)
|
|
200
200
|
case key
|
|
201
|
+
when TerminalKeys::CTRL_PERIOD
|
|
202
|
+
begin_editor_prompt
|
|
201
203
|
when TerminalKeys::CTRL_SPACE
|
|
202
204
|
true
|
|
203
205
|
when TerminalKeys::CTRL_C
|
|
@@ -221,6 +223,8 @@ module Kward
|
|
|
221
223
|
end
|
|
222
224
|
|
|
223
225
|
case normalized_code
|
|
226
|
+
when 46
|
|
227
|
+
begin_editor_prompt
|
|
224
228
|
when 13
|
|
225
229
|
return false if editor_search_active?
|
|
226
230
|
|
|
@@ -192,6 +192,7 @@ module Kward
|
|
|
192
192
|
end
|
|
193
193
|
|
|
194
194
|
def handle_vibe_command_key(key)
|
|
195
|
+
result = nil
|
|
195
196
|
case key
|
|
196
197
|
when "\e", TerminalKeys::CTRL_C, :escape
|
|
197
198
|
@editor_state.vibe_command = ""
|
|
@@ -202,20 +203,21 @@ module Kward
|
|
|
202
203
|
when "\t"
|
|
203
204
|
vibe_complete_command_path
|
|
204
205
|
when "\n", "\r"
|
|
205
|
-
execute_vibe_command(@editor_state.vibe_command)
|
|
206
|
+
result = execute_vibe_command(@editor_state.vibe_command)
|
|
206
207
|
else
|
|
207
208
|
if printable_key?(key)
|
|
208
209
|
@editor_state.vibe_command = @editor_state.vibe_command.to_s + key
|
|
209
210
|
@editor_state.status = ":#{@editor_state.vibe_command}"
|
|
210
211
|
end
|
|
211
212
|
end
|
|
212
|
-
true
|
|
213
|
+
result || true
|
|
213
214
|
end
|
|
214
215
|
|
|
215
216
|
def execute_vibe_command(command)
|
|
216
217
|
command = command.to_s.strip
|
|
217
218
|
@editor_state.vibe_mode = "normal"
|
|
218
219
|
@editor_state.vibe_command = ""
|
|
220
|
+
action = nil
|
|
219
221
|
case command
|
|
220
222
|
when "w"
|
|
221
223
|
save_editor
|
|
@@ -225,6 +227,10 @@ module Kward
|
|
|
225
227
|
vibe_edit_file(Regexp.last_match(2), force: Regexp.last_match(1) == "!")
|
|
226
228
|
when "run"
|
|
227
229
|
vibe_record_undo { run_editor_buffer }
|
|
230
|
+
when /\Aprompt\s+(.+)\z/
|
|
231
|
+
action = editor_prompt_action(Regexp.last_match(1))
|
|
232
|
+
when "prompt"
|
|
233
|
+
@editor_state.status = "Prompt instruction required"
|
|
228
234
|
when "q"
|
|
229
235
|
vibe_quit_editor
|
|
230
236
|
when "q!"
|
|
@@ -244,7 +250,7 @@ module Kward
|
|
|
244
250
|
else
|
|
245
251
|
@editor_state.status = "Unknown command: #{command}"
|
|
246
252
|
end
|
|
247
|
-
true
|
|
253
|
+
action || true
|
|
248
254
|
end
|
|
249
255
|
|
|
250
256
|
def vibe_edit_file(path, force: false)
|
|
@@ -426,7 +432,7 @@ module Kward
|
|
|
426
432
|
def vibe_return_to_normal
|
|
427
433
|
vibe_apply_visual_block_insert if @editor_state.vibe_visual_block_insert
|
|
428
434
|
@editor_state.vibe_mode = "normal"
|
|
429
|
-
@editor_state.status = "NORMAL · i insert · :w save · :q quit"
|
|
435
|
+
@editor_state.status = "NORMAL · i insert · :prompt ask agent · :w save · :q quit"
|
|
430
436
|
true
|
|
431
437
|
end
|
|
432
438
|
|
|
@@ -2239,8 +2245,8 @@ module Kward
|
|
|
2239
2245
|
def vibe_copy_range(start_index, end_index, status, linewise: false)
|
|
2240
2246
|
@editor_state.copy_range(start_index, end_index)
|
|
2241
2247
|
@editor_state.vibe_kill_linewise = linewise
|
|
2242
|
-
|
|
2243
|
-
|
|
2248
|
+
print_output_locked(TerminalSequences.osc52(@editor_state.kill_buffer))
|
|
2249
|
+
flush_output_locked if @output_io.respond_to?(:flush)
|
|
2244
2250
|
@editor_state.status = status
|
|
2245
2251
|
end
|
|
2246
2252
|
|
|
@@ -264,6 +264,7 @@ module Kward
|
|
|
264
264
|
|
|
265
265
|
def editor_status_text
|
|
266
266
|
text = @editor_state.search_active ? "#{@editor_state.search_direction == :backward ? "Search backward" : "Search"}: #{@editor_state.search_query}" : @editor_state.status
|
|
267
|
+
text = "#{text} · #{spinner_frame}" if @editor_agent_suspended && @busy
|
|
267
268
|
visible_truncate(text, [screen_width - 4, 1].max)
|
|
268
269
|
end
|
|
269
270
|
end
|
|
@@ -830,6 +830,17 @@ module Kward
|
|
|
830
830
|
@cursor = @buffer.length
|
|
831
831
|
end
|
|
832
832
|
|
|
833
|
+
def replace_buffer(content)
|
|
834
|
+
content = content.to_s
|
|
835
|
+
return false if content == @buffer
|
|
836
|
+
|
|
837
|
+
push_undo
|
|
838
|
+
self.buffer = content
|
|
839
|
+
@cursor = [@cursor, @buffer.length].min
|
|
840
|
+
changed!
|
|
841
|
+
true
|
|
842
|
+
end
|
|
843
|
+
|
|
833
844
|
def replace_range(start_index, end_index, text)
|
|
834
845
|
start_index, = @text_buffer.replace_range(start_index, end_index, text)
|
|
835
846
|
@buffer = @text_buffer.text
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Interactive terminal UI used by the CLI frontend.
|
|
4
|
+
class PromptInterface
|
|
5
|
+
# Current-buffer word completion for the built-in composer file editor.
|
|
6
|
+
module EditorWordCompletion
|
|
7
|
+
EDITOR_COMPLETION_WORD_PATTERN = /[[:alnum:]_]+/.freeze
|
|
8
|
+
EDITOR_COMPLETION_PREFIX_PATTERN = /[[:alnum:]_]+\z/.freeze
|
|
9
|
+
EDITOR_COMPLETION_SUFFIX_PATTERN = /\A[[:alnum:]_]*/.freeze
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def editor_complete_buffer_word
|
|
14
|
+
if (cycle = matching_editor_completion_cycle)
|
|
15
|
+
return cycle_editor_word_completion(cycle) if cycle[:candidates].length > 1
|
|
16
|
+
|
|
17
|
+
reset_editor_word_completion
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
context = editor_word_completion_context
|
|
21
|
+
return false unless context
|
|
22
|
+
|
|
23
|
+
candidates = editor_word_completion_candidates(context)
|
|
24
|
+
return false if candidates.empty?
|
|
25
|
+
|
|
26
|
+
@editor_word_completion = context.merge(
|
|
27
|
+
editor_state: @editor_state,
|
|
28
|
+
candidates: candidates,
|
|
29
|
+
index: 0,
|
|
30
|
+
previous_status: @editor_state.status
|
|
31
|
+
)
|
|
32
|
+
apply_editor_word_completion(@editor_word_completion)
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def editor_word_completion_context
|
|
37
|
+
buffer = @editor_state.buffer
|
|
38
|
+
cursor = @editor_state.cursor
|
|
39
|
+
prefix_match = buffer[0...cursor].to_s.match(EDITOR_COMPLETION_PREFIX_PATTERN)
|
|
40
|
+
return nil unless prefix_match
|
|
41
|
+
|
|
42
|
+
suffix = buffer[cursor..].to_s.match(EDITOR_COMPLETION_SUFFIX_PATTERN).to_s
|
|
43
|
+
{
|
|
44
|
+
prefix: prefix_match[0],
|
|
45
|
+
range_start: prefix_match.begin(0),
|
|
46
|
+
range_end: cursor + suffix.length
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def editor_word_completion_candidates(context)
|
|
51
|
+
matches = []
|
|
52
|
+
@editor_state.buffer.to_enum(:scan, EDITOR_COMPLETION_WORD_PATTERN).each do
|
|
53
|
+
match = Regexp.last_match
|
|
54
|
+
next if editor_completion_match_overlaps_context?(match, context)
|
|
55
|
+
|
|
56
|
+
word = match[0]
|
|
57
|
+
next unless word.length > context[:prefix].length
|
|
58
|
+
next unless word.start_with?(context[:prefix])
|
|
59
|
+
|
|
60
|
+
matches << [word, match.begin(0)]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
matches
|
|
64
|
+
.sort_by { |_word, offset| editor_completion_candidate_order(offset, context) }
|
|
65
|
+
.map(&:first)
|
|
66
|
+
.uniq
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def editor_completion_match_overlaps_context?(match, context)
|
|
70
|
+
match.begin(0) < context[:range_end] && match.end(0) > context[:range_start]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def editor_completion_candidate_order(offset, context)
|
|
74
|
+
if offset < context[:range_start]
|
|
75
|
+
[0, context[:range_start] - offset]
|
|
76
|
+
else
|
|
77
|
+
[1, offset - context[:range_end]]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def matching_editor_completion_cycle
|
|
82
|
+
cycle = @editor_word_completion
|
|
83
|
+
return nil unless cycle
|
|
84
|
+
|
|
85
|
+
same_editor = cycle[:editor_state].equal?(@editor_state)
|
|
86
|
+
same_buffer = cycle[:buffer] == @editor_state.buffer
|
|
87
|
+
same_cursor = cycle[:cursor] == @editor_state.cursor
|
|
88
|
+
return cycle if same_editor && same_buffer && same_cursor
|
|
89
|
+
|
|
90
|
+
reset_editor_word_completion
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cycle_editor_word_completion(cycle)
|
|
95
|
+
cycle[:index] = (cycle[:index] + 1) % cycle[:candidates].length
|
|
96
|
+
apply_editor_word_completion(cycle)
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def apply_editor_word_completion(cycle)
|
|
101
|
+
candidate = cycle[:candidates][cycle[:index]]
|
|
102
|
+
@editor_state.replace_range(cycle[:range_start], cycle[:range_end], candidate)
|
|
103
|
+
@editor_state.cursor = cycle[:range_start] + candidate.length
|
|
104
|
+
cycle[:range_end] = @editor_state.cursor
|
|
105
|
+
cycle[:buffer] = @editor_state.buffer
|
|
106
|
+
cycle[:cursor] = @editor_state.cursor
|
|
107
|
+
cycle[:status] = "Completion #{cycle[:index] + 1}/#{cycle[:candidates].length}: #{candidate}"
|
|
108
|
+
@editor_state.status = cycle[:status]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def reset_editor_word_completion
|
|
112
|
+
cycle = @editor_word_completion
|
|
113
|
+
if cycle && cycle[:editor_state].status == cycle[:status]
|
|
114
|
+
cycle[:editor_state].status = cycle[:previous_status]
|
|
115
|
+
end
|
|
116
|
+
@editor_word_completion = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def editor_word_completion_tab_key?(key)
|
|
120
|
+
!editor_tab_sequence_for(key).nil?
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -114,8 +114,12 @@ module Kward
|
|
|
114
114
|
end
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
-
def project_file_paths
|
|
118
|
-
|
|
117
|
+
def project_file_paths(include_ignored: false)
|
|
118
|
+
if include_ignored
|
|
119
|
+
@project_browser_file_paths ||= discover_project_file_paths(include_ignored: true)
|
|
120
|
+
else
|
|
121
|
+
@file_mention_paths ||= discover_project_file_paths
|
|
122
|
+
end
|
|
119
123
|
end
|
|
120
124
|
|
|
121
125
|
def project_file_path_entries
|
|
@@ -126,14 +130,24 @@ module Kward
|
|
|
126
130
|
@file_mention_path_entries = paths.map { |path| { path: path, downcase: path.downcase } }
|
|
127
131
|
end
|
|
128
132
|
|
|
129
|
-
def discover_project_file_paths
|
|
130
|
-
paths =
|
|
131
|
-
|
|
133
|
+
def discover_project_file_paths(include_ignored: false)
|
|
134
|
+
paths = if include_ignored
|
|
135
|
+
git_project_file_paths(include_ignored: true)
|
|
136
|
+
else
|
|
137
|
+
git_project_file_paths
|
|
138
|
+
end
|
|
139
|
+
paths = scanned_project_file_paths if paths.empty? && !git_project_repository?
|
|
132
140
|
paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
|
|
133
141
|
end
|
|
134
142
|
|
|
135
|
-
def git_project_file_paths
|
|
136
|
-
ProjectFiles.git_paths(prompt_workspace_root)
|
|
143
|
+
def git_project_file_paths(include_ignored: false)
|
|
144
|
+
return ProjectFiles.git_paths(prompt_workspace_root) unless include_ignored
|
|
145
|
+
|
|
146
|
+
ProjectFiles.git_paths(prompt_workspace_root, include_ignored: true)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def git_project_repository?
|
|
150
|
+
ProjectFiles.git_repository?(prompt_workspace_root)
|
|
137
151
|
end
|
|
138
152
|
|
|
139
153
|
def scanned_project_file_paths
|
|
@@ -20,6 +20,14 @@ module Kward
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def handle_editor_input_key(key)
|
|
23
|
+
if @editor_agent_suspended
|
|
24
|
+
return CANCEL_INPUT if @busy && key == TerminalKeys::CTRL_C
|
|
25
|
+
|
|
26
|
+
return true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
reset_editor_word_completion unless editor_word_completion_tab_key?(key)
|
|
30
|
+
|
|
23
31
|
tab_result = handle_tab_key_binding(key)
|
|
24
32
|
return tab_result unless tab_result == false
|
|
25
33
|
|
|
@@ -27,9 +35,31 @@ module Kward
|
|
|
27
35
|
result.is_a?(String) ? true : result
|
|
28
36
|
end
|
|
29
37
|
|
|
38
|
+
def handle_image_viewer_key(key)
|
|
39
|
+
return true if handle_bundled_key(key) { |token| handle_image_viewer_key(token) }
|
|
40
|
+
|
|
41
|
+
sequence = parse_csi_u_key(key)
|
|
42
|
+
if sequence
|
|
43
|
+
queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
|
|
44
|
+
return close_image_viewer if sequence[:code] == 27
|
|
45
|
+
|
|
46
|
+
text = csi_u_printable_text(sequence)
|
|
47
|
+
return zoom_image_viewer(:in) if text == "+"
|
|
48
|
+
return zoom_image_viewer(:out) if text == "-"
|
|
49
|
+
return true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return zoom_image_viewer(:in) if key == "+"
|
|
53
|
+
return zoom_image_viewer(:out) if key == "-"
|
|
54
|
+
|
|
55
|
+
close_image_viewer if key == "\e" || key == "q" || key == "Q"
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
30
59
|
def handle_key(key)
|
|
31
60
|
return submit_input if key.nil?
|
|
32
61
|
return handle_interactive_key(key) if interactive_active_locked?
|
|
62
|
+
return handle_image_viewer_key(key) if image_viewer_active?
|
|
33
63
|
return handle_editor_input_key(key) if editor_active?
|
|
34
64
|
return handle_project_browser_key(key) if project_browser_visible?
|
|
35
65
|
return handle_history_search_key(key) if history_search_active?
|
|
@@ -10,6 +10,7 @@ module Kward
|
|
|
10
10
|
return question_overlay_rows(width) if @question_state
|
|
11
11
|
return selection_overlay_rows(width, height: height) if @select_state
|
|
12
12
|
return git_overlay_rows(width, height: height) if @git_state
|
|
13
|
+
return image_viewer_rows(width, height: height) if image_viewer_active?
|
|
13
14
|
return project_browser_rows(width, height: height) if project_browser_visible?
|
|
14
15
|
return completion_overlay_rows(width, height: height) if @completion_overlay
|
|
15
16
|
return history_search_overlay_rows(width, height: height) if history_search_active?
|