kward 0.83.0 → 0.84.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 +68 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/api.md +4 -0
- data/doc/composer.md +1 -1
- data/doc/configuration.md +68 -21
- data/doc/editor.md +28 -13
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/pan.md +19 -15
- data/doc/platform-support.md +48 -0
- data/doc/security.md +3 -2
- data/doc/shell.md +62 -45
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +5 -5
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +6 -3
- data/lib/kward/ansi.rb +110 -10
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +48 -7
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +27 -0
- data/lib/kward/cli/rendering.rb +15 -9
- data/lib/kward/cli/runtime_helpers.rb +118 -55
- data/lib/kward/cli/slash_commands.rb +12 -14
- data/lib/kward/cli/tabs.rb +83 -12
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/cli.rb +45 -7
- data/lib/kward/cli_transcript_formatter.rb +11 -4
- data/lib/kward/config_files.rb +82 -68
- data/lib/kward/detached_run.rb +44 -0
- data/lib/kward/interactive_pty_runner.rb +102 -28
- data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
- data/lib/kward/kwshrc.rb +233 -0
- data/lib/kward/markdown_code_block.rb +136 -0
- data/lib/kward/model/client.rb +34 -22
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +23 -3
- data/lib/kward/persistent_shell_session.rb +119 -26
- data/lib/kward/project_files.rb +2 -2
- data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
- data/lib/kward/prompt_interface/editor/controller.rb +30 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
- data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/state.rb +10 -10
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/file_overlay.rb +71 -15
- data/lib/kward/prompt_interface/key_handler.rb +67 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +50 -1
- data/lib/kward/prompt_interface/screen.rb +2 -2
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +93 -18
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/pty_output_sink.rb +47 -0
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/scratchpad_languages.rb +74 -0
- data/lib/kward/scratchpad_runner.rb +155 -29
- data/lib/kward/shell_prompt.rb +2 -0
- data/lib/kward/terminal_keys.rb +12 -0
- data/lib/kward/terminal_text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/registry.rb +13 -4
- data/lib/kward/tools/tool_call.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/fulldoc/html/css/kward.css +0 -125
- data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
- data/templates/default/fulldoc/html/setup.rb +1 -1
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +6 -18
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +36 -8
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "fileutils"
|
|
1
2
|
require "json"
|
|
2
3
|
require "set"
|
|
3
4
|
require_relative "../config_files"
|
|
@@ -58,7 +59,8 @@ module Kward
|
|
|
58
59
|
selection_index: 0,
|
|
59
60
|
search_active: false,
|
|
60
61
|
query: "",
|
|
61
|
-
status: nil
|
|
62
|
+
status: nil,
|
|
63
|
+
directory_paths: restored_project_browser_directory_paths(saved_state)
|
|
62
64
|
}
|
|
63
65
|
restore_project_browser_selection(saved_state["selected_path"])
|
|
64
66
|
self.composer_input = ""
|
|
@@ -86,6 +88,8 @@ module Kward
|
|
|
86
88
|
end
|
|
87
89
|
|
|
88
90
|
def handle_project_browser_key(key)
|
|
91
|
+
return handle_project_browser_name_key(key) if project_browser_name_entry_active?
|
|
92
|
+
return handle_project_browser_delete_confirmation_key(key) if project_browser_delete_confirmation_active?
|
|
89
93
|
return true if handle_bundled_key(key) { |token| handle_project_browser_key(token) }
|
|
90
94
|
|
|
91
95
|
csi_result = handle_project_browser_csi_u_key(key)
|
|
@@ -95,7 +99,7 @@ module Kward
|
|
|
95
99
|
when :return, :enter
|
|
96
100
|
open_or_toggle_selected_project_browser_row
|
|
97
101
|
when :backspace
|
|
98
|
-
project_browser_delete_search_character
|
|
102
|
+
project_browser_search_active? ? project_browser_delete_search_character : request_project_browser_delete
|
|
99
103
|
when :ctrl_l
|
|
100
104
|
redraw_screen_locked
|
|
101
105
|
when :left
|
|
@@ -123,7 +127,7 @@ module Kward
|
|
|
123
127
|
when 27
|
|
124
128
|
project_browser_escape
|
|
125
129
|
when 8, 127
|
|
126
|
-
project_browser_delete_search_character
|
|
130
|
+
project_browser_search_active? ? project_browser_delete_search_character : request_project_browser_delete
|
|
127
131
|
when 9
|
|
128
132
|
return false if ctrl_modifier?(sequence[:modifier]) || alt_modifier?(sequence[:modifier]) || super_modifier?(sequence[:modifier])
|
|
129
133
|
|
|
@@ -146,6 +150,8 @@ module Kward
|
|
|
146
150
|
expand_selected_project_browser_row
|
|
147
151
|
elsif text == "i" && !project_browser_search_active?
|
|
148
152
|
toggle_project_browser_ignored_files
|
|
153
|
+
elsif ["f", "r", "d"].include?(text) && !project_browser_search_active?
|
|
154
|
+
start_project_browser_name_entry(text)
|
|
149
155
|
elsif project_browser_search_active?
|
|
150
156
|
project_browser_append_search(text)
|
|
151
157
|
else
|
|
@@ -160,7 +166,7 @@ module Kward
|
|
|
160
166
|
when "\n", "\r"
|
|
161
167
|
open_or_toggle_selected_project_browser_row
|
|
162
168
|
when "\b", "\x7F"
|
|
163
|
-
project_browser_delete_search_character
|
|
169
|
+
project_browser_search_active? ? project_browser_delete_search_character : request_project_browser_delete
|
|
164
170
|
when "\e"
|
|
165
171
|
project_browser_escape
|
|
166
172
|
when "@"
|
|
@@ -179,6 +185,8 @@ module Kward
|
|
|
179
185
|
project_browser_search_active? ? project_browser_append_search(key) : expand_selected_project_browser_row
|
|
180
186
|
when "i"
|
|
181
187
|
project_browser_search_active? ? project_browser_append_search(key) : toggle_project_browser_ignored_files
|
|
188
|
+
when "f", "r", "d"
|
|
189
|
+
start_project_browser_name_entry(key) unless project_browser_search_active?
|
|
182
190
|
else
|
|
183
191
|
project_browser_append_search(key) if project_browser_search_active? && printable_key?(key)
|
|
184
192
|
end
|
|
@@ -192,6 +200,371 @@ module Kward
|
|
|
192
200
|
end
|
|
193
201
|
end
|
|
194
202
|
|
|
203
|
+
def project_browser_delete_confirmation_active?
|
|
204
|
+
@project_browser_state && @project_browser_state[:delete_confirmation]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def request_project_browser_delete
|
|
208
|
+
row = selected_project_browser_row
|
|
209
|
+
return false unless row
|
|
210
|
+
|
|
211
|
+
path = project_browser_path_for(row[:path])
|
|
212
|
+
return false unless path && path != File.expand_path(prompt_workspace_root)
|
|
213
|
+
return false unless project_browser_parent_available?(File.dirname(path))
|
|
214
|
+
|
|
215
|
+
directory = row[:directory] && !File.symlink?(path)
|
|
216
|
+
@project_browser_state[:delete_confirmation] = {
|
|
217
|
+
path: row[:path],
|
|
218
|
+
directory: directory,
|
|
219
|
+
recursive: directory && project_browser_directory_nonempty?(path)
|
|
220
|
+
}
|
|
221
|
+
@project_browser_state[:status] = nil
|
|
222
|
+
true
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def handle_project_browser_delete_confirmation_key(key)
|
|
226
|
+
return true if handle_bundled_key(key) { |token| handle_project_browser_delete_confirmation_key(token) }
|
|
227
|
+
|
|
228
|
+
sequence = parse_csi_u_key(key)
|
|
229
|
+
if sequence
|
|
230
|
+
queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
|
|
231
|
+
return cancel_project_browser_delete if sequence[:code] == 27
|
|
232
|
+
return confirm_project_browser_delete if [8, 127].include?(sequence[:code])
|
|
233
|
+
|
|
234
|
+
return true
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
return cancel_project_browser_delete if key == "\e"
|
|
238
|
+
return confirm_project_browser_delete if key == "\b" || key == "\x7F" || key_name_for(key) == :backspace
|
|
239
|
+
|
|
240
|
+
true
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def project_browser_directory_nonempty?(path)
|
|
244
|
+
!Dir.empty?(path)
|
|
245
|
+
rescue SystemCallError
|
|
246
|
+
true
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def cancel_project_browser_delete
|
|
250
|
+
@project_browser_state.delete(:delete_confirmation)
|
|
251
|
+
@project_browser_state[:status] = nil
|
|
252
|
+
true
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def confirm_project_browser_delete
|
|
256
|
+
confirmation = @project_browser_state[:delete_confirmation]
|
|
257
|
+
return false unless confirmation
|
|
258
|
+
|
|
259
|
+
path = project_browser_path_for(confirmation[:path])
|
|
260
|
+
unless path && path != File.expand_path(prompt_workspace_root) && path_exists?(path) && project_browser_parent_available?(File.dirname(path))
|
|
261
|
+
@project_browser_state.delete(:delete_confirmation)
|
|
262
|
+
@project_browser_state[:status] = "Entry is no longer available"
|
|
263
|
+
return true
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
if confirmation[:directory] && !confirmation[:recursive] && project_browser_directory_nonempty?(path)
|
|
267
|
+
confirmation[:recursive] = true
|
|
268
|
+
return true
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
begin
|
|
272
|
+
if confirmation[:directory]
|
|
273
|
+
FileUtils.rm_r(path)
|
|
274
|
+
else
|
|
275
|
+
FileUtils.rm(path)
|
|
276
|
+
end
|
|
277
|
+
rescue SystemCallError
|
|
278
|
+
@project_browser_state.delete(:delete_confirmation)
|
|
279
|
+
@project_browser_state[:status] = "Unable to delete #{confirmation[:path]}"
|
|
280
|
+
return true
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
deleted_path = confirmation[:path]
|
|
284
|
+
@project_browser_state[:directory_paths].delete_if do |directory|
|
|
285
|
+
directory == deleted_path || directory.start_with?("#{deleted_path}/")
|
|
286
|
+
end
|
|
287
|
+
@project_browser_state.delete(:delete_confirmation)
|
|
288
|
+
refresh_project_browser_paths
|
|
289
|
+
restore_project_browser_selection(File.dirname(deleted_path))
|
|
290
|
+
@project_browser_state[:status] = "Deleted: #{deleted_path}"
|
|
291
|
+
persist_project_browser_state
|
|
292
|
+
true
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def project_browser_delete_confirmation_rows(width)
|
|
296
|
+
confirmation = @project_browser_state[:delete_confirmation]
|
|
297
|
+
path = confirmation[:path]
|
|
298
|
+
lines = [overlay_text_line("Delete #{confirmation[:directory] ? "directory" : "file"}?"), overlay_blank_line]
|
|
299
|
+
if confirmation[:recursive]
|
|
300
|
+
lines << overlay_text_line("This will permanently delete #{path} and its contents.", :muted)
|
|
301
|
+
lines << overlay_text_line("Press Backspace again to confirm • Esc to cancel", :muted)
|
|
302
|
+
else
|
|
303
|
+
lines << overlay_text_line(path)
|
|
304
|
+
lines << overlay_text_line("Press Backspace again to confirm • Esc to cancel", :muted)
|
|
305
|
+
end
|
|
306
|
+
overlay_card_rows("Confirm deletion", lines, width)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def project_browser_name_entry_active?
|
|
310
|
+
@project_browser_state && @project_browser_state[:name_entry]
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def start_project_browser_name_entry(action)
|
|
314
|
+
row = selected_project_browser_row
|
|
315
|
+
return false if action == "r" && !row
|
|
316
|
+
|
|
317
|
+
if action == "r"
|
|
318
|
+
parent = File.dirname(row[:path].to_s)
|
|
319
|
+
parent = PROJECT_BROWSER_ROOT if parent == "."
|
|
320
|
+
initial_name = row[:name]
|
|
321
|
+
source = row[:path]
|
|
322
|
+
else
|
|
323
|
+
parent = if row
|
|
324
|
+
row[:directory] ? row[:path] : File.dirname(row[:path].to_s)
|
|
325
|
+
else
|
|
326
|
+
PROJECT_BROWSER_ROOT
|
|
327
|
+
end
|
|
328
|
+
parent = PROJECT_BROWSER_ROOT if parent == "."
|
|
329
|
+
initial_name = ""
|
|
330
|
+
source = nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
@project_browser_state[:name_entry] = {
|
|
334
|
+
action: action,
|
|
335
|
+
parent: parent,
|
|
336
|
+
source: source
|
|
337
|
+
}
|
|
338
|
+
@project_browser_state[:status] = nil
|
|
339
|
+
@project_browser_state[:prompt_label] = @prompt_label
|
|
340
|
+
@prompt_label = project_browser_name_prompt(action)
|
|
341
|
+
self.composer_input = initial_name
|
|
342
|
+
self.composer_cursor = initial_name.length
|
|
343
|
+
true
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def project_browser_name_prompt(action)
|
|
347
|
+
case action
|
|
348
|
+
when "f" then "New file>"
|
|
349
|
+
when "d" then "New directory>"
|
|
350
|
+
else "Rename>"
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def handle_project_browser_name_key(key)
|
|
355
|
+
return true if handle_bracketed_paste(key) { |content| insert_string(normalize_paste(content)) }
|
|
356
|
+
|
|
357
|
+
sequence = parse_csi_u_key(key)
|
|
358
|
+
if sequence
|
|
359
|
+
queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
|
|
360
|
+
case sequence[:code]
|
|
361
|
+
when 13
|
|
362
|
+
return submit_project_browser_name_entry
|
|
363
|
+
when 27
|
|
364
|
+
return cancel_project_browser_name_entry
|
|
365
|
+
when 8, 127
|
|
366
|
+
alt_modifier?(sequence[:modifier]) ? delete_word_before_cursor : delete_before_cursor
|
|
367
|
+
return true
|
|
368
|
+
else
|
|
369
|
+
modified_result = project_browser_name_modified_key(sequence)
|
|
370
|
+
return modified_result unless modified_result == false
|
|
371
|
+
insert_csi_u_text(sequence)
|
|
372
|
+
return true
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
return true if handle_bundled_key(key) { |token| handle_project_browser_name_key(token) }
|
|
377
|
+
|
|
378
|
+
case key
|
|
379
|
+
when "\n", "\r"
|
|
380
|
+
submit_project_browser_name_entry
|
|
381
|
+
when "\e", TerminalKeys::CTRL_C
|
|
382
|
+
cancel_project_browser_name_entry
|
|
383
|
+
when "\b", "\x7F"
|
|
384
|
+
delete_before_cursor
|
|
385
|
+
else
|
|
386
|
+
key_name = key_name_for(key)
|
|
387
|
+
return true if project_browser_name_named_key(key_name)
|
|
388
|
+
|
|
389
|
+
binding_result = handle_composer_key_binding(key)
|
|
390
|
+
return binding_result unless binding_result == false || binding_result.nil?
|
|
391
|
+
|
|
392
|
+
insert_key(key)
|
|
393
|
+
end
|
|
394
|
+
true
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def project_browser_name_modified_key(sequence)
|
|
398
|
+
return false unless ctrl_modifier?(sequence[:modifier]) || alt_modifier?(sequence[:modifier])
|
|
399
|
+
|
|
400
|
+
code = ctrl_code_for(sequence[:code])
|
|
401
|
+
if ctrl_modifier?(sequence[:modifier])
|
|
402
|
+
case code
|
|
403
|
+
when 97 then move_to_start_of_line
|
|
404
|
+
when 98 then move_cursor_left
|
|
405
|
+
when 99 then cancel_project_browser_name_entry
|
|
406
|
+
when 100 then delete_at_cursor
|
|
407
|
+
when 101 then move_to_end_of_line
|
|
408
|
+
when 102 then move_cursor_right
|
|
409
|
+
when 107 then kill_line_after_cursor
|
|
410
|
+
when 117 then kill_line_before_cursor
|
|
411
|
+
when 119 then delete_word_before_cursor
|
|
412
|
+
else return false
|
|
413
|
+
end
|
|
414
|
+
elsif alt_modifier?(sequence[:modifier])
|
|
415
|
+
case code
|
|
416
|
+
when 98 then move_to_previous_word
|
|
417
|
+
when 100 then delete_word_after_cursor
|
|
418
|
+
when 102 then move_to_next_word
|
|
419
|
+
else return false
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
true
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def project_browser_name_named_key(key_name)
|
|
426
|
+
case key_name
|
|
427
|
+
when :left then move_cursor_left
|
|
428
|
+
when :right then move_cursor_right
|
|
429
|
+
when :home then move_to_start_of_line
|
|
430
|
+
when :end then move_to_end_of_line
|
|
431
|
+
when :delete then delete_at_cursor
|
|
432
|
+
else false
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def cancel_project_browser_name_entry
|
|
437
|
+
entry = @project_browser_state.delete(:name_entry)
|
|
438
|
+
return false unless entry
|
|
439
|
+
|
|
440
|
+
@prompt_label = @project_browser_state.delete(:prompt_label) || "You>"
|
|
441
|
+
@project_browser_state[:status] = nil
|
|
442
|
+
self.composer_input = ""
|
|
443
|
+
self.composer_cursor = 0
|
|
444
|
+
true
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def submit_project_browser_name_entry
|
|
448
|
+
entry = @project_browser_state[:name_entry]
|
|
449
|
+
return false unless entry
|
|
450
|
+
|
|
451
|
+
name = composer_input
|
|
452
|
+
error = project_browser_name_error(name, entry)
|
|
453
|
+
if error
|
|
454
|
+
@project_browser_state[:status] = error
|
|
455
|
+
return true
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
source_is_directory = entry[:action] == "r" && directory_path?(entry[:source])
|
|
459
|
+
begin
|
|
460
|
+
project_browser_apply_name_entry(name, entry)
|
|
461
|
+
rescue SystemCallError
|
|
462
|
+
@project_browser_state[:status] = "Unable to #{project_browser_name_action(entry[:action])} #{name}"
|
|
463
|
+
return true
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
target = project_browser_target_path(entry[:parent], name)
|
|
467
|
+
@project_browser_state[:directory_paths].add(target) if entry[:action] == "d"
|
|
468
|
+
if source_is_directory
|
|
469
|
+
old_prefix = entry[:source]
|
|
470
|
+
@project_browser_state[:directory_paths] = Set.new(@project_browser_state[:directory_paths].map do |path|
|
|
471
|
+
path == old_prefix || path.start_with?("#{old_prefix}/") ? target + path.delete_prefix(old_prefix) : path
|
|
472
|
+
end)
|
|
473
|
+
end
|
|
474
|
+
refresh_project_browser_paths
|
|
475
|
+
expand_project_browser_ancestors(target)
|
|
476
|
+
@project_browser_state.delete(:name_entry)
|
|
477
|
+
@prompt_label = @project_browser_state.delete(:prompt_label) || "You>"
|
|
478
|
+
self.composer_input = ""
|
|
479
|
+
self.composer_cursor = 0
|
|
480
|
+
restore_project_browser_selection(target)
|
|
481
|
+
@project_browser_state[:status] = "#{project_browser_name_action(entry[:action]).capitalize}: #{target}"
|
|
482
|
+
persist_project_browser_state
|
|
483
|
+
true
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def project_browser_name_error(name, entry)
|
|
487
|
+
return "Name cannot be empty" if name.empty? || name.strip.empty?
|
|
488
|
+
return "Name must be a single entry name" if name.include?("/") || name.include?("\\") || name.include?("\0")
|
|
489
|
+
return "Name cannot be . or .." if [".", ".."].include?(name)
|
|
490
|
+
|
|
491
|
+
parent = project_browser_path_for(entry[:parent])
|
|
492
|
+
return "Parent directory is unavailable" unless project_browser_parent_available?(parent)
|
|
493
|
+
|
|
494
|
+
target = project_browser_target_path(entry[:parent], name)
|
|
495
|
+
target_path = project_browser_path_for(target)
|
|
496
|
+
source_path = project_browser_path_for(entry[:source])
|
|
497
|
+
return "Name already exists" if path_exists?(target_path) && target_path != source_path
|
|
498
|
+
|
|
499
|
+
nil
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def project_browser_apply_name_entry(name, entry)
|
|
503
|
+
target = project_browser_path_for(project_browser_target_path(entry[:parent], name))
|
|
504
|
+
case entry[:action]
|
|
505
|
+
when "f"
|
|
506
|
+
FileUtils.touch(target)
|
|
507
|
+
when "d"
|
|
508
|
+
Dir.mkdir(target)
|
|
509
|
+
when "r"
|
|
510
|
+
File.rename(project_browser_path_for(entry[:source]), target)
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def project_browser_name_action(action)
|
|
515
|
+
{ "f" => "create file", "d" => "create directory", "r" => "rename" }.fetch(action)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def project_browser_target_path(parent, name)
|
|
519
|
+
[parent, name].reject(&:empty?).join("/")
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def project_browser_path_for(path)
|
|
523
|
+
root = File.expand_path(prompt_workspace_root)
|
|
524
|
+
full_path = File.expand_path(path.to_s, root)
|
|
525
|
+
return nil unless full_path == root || full_path.start_with?("#{root}/")
|
|
526
|
+
|
|
527
|
+
full_path
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def project_browser_parent_available?(path)
|
|
531
|
+
return false unless path && File.directory?(path)
|
|
532
|
+
|
|
533
|
+
root = File.realpath(prompt_workspace_root)
|
|
534
|
+
real_path = File.realpath(path)
|
|
535
|
+
real_path == root || real_path.start_with?("#{root}/")
|
|
536
|
+
rescue SystemCallError
|
|
537
|
+
false
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def path_exists?(path)
|
|
541
|
+
path && (File.exist?(path) || File.symlink?(path))
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def directory_path?(path)
|
|
545
|
+
path && File.directory?(project_browser_path_for(path))
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def refresh_project_browser_paths
|
|
549
|
+
@file_mention_paths = nil
|
|
550
|
+
@file_mention_path_entries = nil
|
|
551
|
+
@file_mention_path_entries_paths = nil
|
|
552
|
+
@project_browser_file_paths = nil
|
|
553
|
+
paths = project_file_paths(include_ignored: project_browser_ignored_files_visible?)
|
|
554
|
+
@project_browser_state[:paths] = paths
|
|
555
|
+
@project_browser_tree_paths = nil
|
|
556
|
+
@project_browser_tree = nil
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def expand_project_browser_ancestors(path)
|
|
560
|
+
parent = File.dirname(path.to_s)
|
|
561
|
+
until parent == "." || parent.empty?
|
|
562
|
+
@project_browser_state[:expanded].add(parent)
|
|
563
|
+
parent = File.dirname(parent)
|
|
564
|
+
end
|
|
565
|
+
@project_browser_state[:expanded].add(PROJECT_BROWSER_ROOT)
|
|
566
|
+
end
|
|
567
|
+
|
|
195
568
|
def toggle_project_browser_search
|
|
196
569
|
project_browser_search_active? ? deactivate_project_browser_search : activate_project_browser_search
|
|
197
570
|
end
|
|
@@ -499,7 +872,8 @@ module Kward
|
|
|
499
872
|
selection_index: 0,
|
|
500
873
|
search_active: false,
|
|
501
874
|
query: "",
|
|
502
|
-
status: nil
|
|
875
|
+
status: nil,
|
|
876
|
+
directory_paths: restored_project_browser_directory_paths(saved_state)
|
|
503
877
|
}
|
|
504
878
|
restore_project_browser_selection(saved_state["selected_path"])
|
|
505
879
|
end
|
|
@@ -509,6 +883,7 @@ module Kward
|
|
|
509
883
|
|
|
510
884
|
def project_browser_rows(width, height: screen_height)
|
|
511
885
|
return [] unless project_browser_visible?
|
|
886
|
+
return project_browser_delete_confirmation_rows(width) if project_browser_delete_confirmation_active?
|
|
512
887
|
|
|
513
888
|
rows = project_browser_visible_rows
|
|
514
889
|
lines = []
|
|
@@ -542,7 +917,7 @@ module Kward
|
|
|
542
917
|
if project_browser_search_active?
|
|
543
918
|
"Type search • Esc tree • Enter open • @ mention"
|
|
544
919
|
else
|
|
545
|
-
"Enter open/toggle • i show/hide ignored • ←/→ collapse/expand • Tab or / search • @ mention • Esc close"
|
|
920
|
+
"f new file • d new directory • r rename • Backspace delete • Enter open/toggle • i show/hide ignored • ←/→ collapse/expand • Tab or / search • @ mention • Esc close"
|
|
546
921
|
end
|
|
547
922
|
end
|
|
548
923
|
|
|
@@ -579,14 +954,20 @@ module Kward
|
|
|
579
954
|
|
|
580
955
|
def project_browser_search_rows
|
|
581
956
|
query = @project_browser_state[:query].downcase
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
957
|
+
entries = project_file_path_entries
|
|
958
|
+
cache = @project_browser_search_cache
|
|
959
|
+
return cache[:rows] if cache && cache[:query] == query && cache[:entries].equal?(entries)
|
|
585
960
|
|
|
586
|
-
|
|
587
|
-
|
|
961
|
+
pattern = TextMatcher.subsequence_pattern(query)
|
|
962
|
+
rows = []
|
|
963
|
+
entries.each do |entry|
|
|
964
|
+
next unless TextMatcher.subsequence?(entry[:downcase], query, pattern)
|
|
965
|
+
|
|
966
|
+
rows << { path: entry[:path], name: entry[:path], depth: 0, directory: false }
|
|
967
|
+
break if rows.length >= PROJECT_BROWSER_RESULT_LIMIT
|
|
588
968
|
end
|
|
589
|
-
|
|
969
|
+
@project_browser_search_cache = { query: query, entries: entries, rows: rows }
|
|
970
|
+
rows
|
|
590
971
|
end
|
|
591
972
|
|
|
592
973
|
def project_browser_tree
|
|
@@ -595,6 +976,15 @@ module Kward
|
|
|
595
976
|
|
|
596
977
|
directories = Hash.new { |hash, key| hash[key] = Set.new }
|
|
597
978
|
files = Hash.new { |hash, key| hash[key] = [] }
|
|
979
|
+
@project_browser_state.fetch(:directory_paths, Set.new).each do |path|
|
|
980
|
+
parts = path.split("/")
|
|
981
|
+
parent = PROJECT_BROWSER_ROOT
|
|
982
|
+
parts.each do |part|
|
|
983
|
+
directory = parent.empty? ? part : "#{parent}/#{part}"
|
|
984
|
+
directories[parent].add(directory)
|
|
985
|
+
parent = directory
|
|
986
|
+
end
|
|
987
|
+
end
|
|
598
988
|
paths.each do |path|
|
|
599
989
|
parts = path.split("/")
|
|
600
990
|
parent = PROJECT_BROWSER_ROOT
|
|
@@ -656,7 +1046,8 @@ module Kward
|
|
|
656
1046
|
workspaces[project_browser_workspace_root] = {
|
|
657
1047
|
"expanded" => @project_browser_state[:expanded].to_a.sort,
|
|
658
1048
|
"selected_path" => row && row[:path],
|
|
659
|
-
"show_ignored" => project_browser_ignored_files_visible
|
|
1049
|
+
"show_ignored" => project_browser_ignored_files_visible?,
|
|
1050
|
+
"created_directories" => @project_browser_state.fetch(:directory_paths, Set.new).to_a.sort
|
|
660
1051
|
}
|
|
661
1052
|
data["version"] = PROJECT_BROWSER_STATE_VERSION
|
|
662
1053
|
data["workspaces"] = workspaces
|
|
@@ -679,8 +1070,18 @@ module Kward
|
|
|
679
1070
|
ConfigFiles.canonical_workspace_root(prompt_workspace_root)
|
|
680
1071
|
end
|
|
681
1072
|
|
|
1073
|
+
def restored_project_browser_directory_paths(saved_state)
|
|
1074
|
+
Array(saved_state["created_directories"]).each_with_object(Set.new) do |path, directories|
|
|
1075
|
+
next unless path.is_a?(String) && path.match?(/\A[^\\\0]+(?:\/[^\\\0]+)*\z/)
|
|
1076
|
+
next if path.split("/").any? { |part| part.empty? || [".", ".."].include?(part) }
|
|
1077
|
+
next unless project_browser_parent_available?(project_browser_path_for(path)) && File.directory?(project_browser_path_for(path))
|
|
1078
|
+
|
|
1079
|
+
directories.add(path)
|
|
1080
|
+
end
|
|
1081
|
+
end
|
|
1082
|
+
|
|
682
1083
|
def restored_project_browser_expanded_paths(paths, saved_state)
|
|
683
|
-
directories = project_browser_directory_paths(paths)
|
|
1084
|
+
directories = project_browser_directory_paths(paths) | restored_project_browser_directory_paths(saved_state)
|
|
684
1085
|
saved_expanded = saved_state["expanded"]
|
|
685
1086
|
expanded = if saved_expanded.is_a?(Array)
|
|
686
1087
|
Set.new(saved_expanded.select { |path| directories.include?(path.to_s) })
|
|
@@ -13,7 +13,7 @@ module Kward
|
|
|
13
13
|
|
|
14
14
|
def normalize_busy_activity(activity)
|
|
15
15
|
text = activity.to_s.gsub(/\s+/, " ").strip
|
|
16
|
-
text.empty? ? "
|
|
16
|
+
text.empty? ? "thinking" : text
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def tick_spinner_locked
|
|
@@ -38,6 +38,55 @@ module Kward
|
|
|
38
38
|
busy && @queued_count.zero? && @started && @asking
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
def completion_status(status)
|
|
42
|
+
text = case status&.to_sym
|
|
43
|
+
when :success
|
|
44
|
+
["✓ Complete", :success]
|
|
45
|
+
when :failed
|
|
46
|
+
["× Failed", :failure]
|
|
47
|
+
when :cancelled
|
|
48
|
+
["– Cancelled", :metadata]
|
|
49
|
+
else
|
|
50
|
+
return nil
|
|
51
|
+
end
|
|
52
|
+
duration = formatted_elapsed(@last_busy_elapsed)
|
|
53
|
+
label = [text.first, duration].compact.join(" · ")
|
|
54
|
+
{ text: label, style: text.last, expires_at: monotonic_now + COMPLETION_DISPLAY_SECONDS }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def response_arriving?
|
|
58
|
+
return false unless @response_arrival_until
|
|
59
|
+
|
|
60
|
+
if monotonic_now >= @response_arrival_until
|
|
61
|
+
@response_arrival_until = nil
|
|
62
|
+
return false
|
|
63
|
+
end
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def busy_elapsed_text
|
|
68
|
+
return nil unless @busy_started_at
|
|
69
|
+
|
|
70
|
+
elapsed = monotonic_now - @busy_started_at
|
|
71
|
+
return nil if elapsed < BUSY_ELAPSED_DELAY_SECONDS
|
|
72
|
+
|
|
73
|
+
formatted_elapsed(elapsed)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def formatted_elapsed(seconds)
|
|
77
|
+
return nil unless seconds
|
|
78
|
+
|
|
79
|
+
format("%.1fs", seconds)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def tick_completion_locked
|
|
83
|
+
return false unless @completion_status
|
|
84
|
+
return false if monotonic_now < @completion_status[:expires_at]
|
|
85
|
+
|
|
86
|
+
@completion_status = nil
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
41
90
|
def tick_footer_locked
|
|
42
91
|
return false unless @footer && @started && @asking
|
|
43
92
|
return false unless footer_refresh_due?
|
|
@@ -79,8 +79,8 @@ module Kward
|
|
|
79
79
|
visible = !(@question_state && !selected_question_choice&.fetch(:custom, false))
|
|
80
80
|
visible = select_editing_active? if @select_state
|
|
81
81
|
visible = git_composing? if @git_state
|
|
82
|
-
visible = project_browser_search_active? if project_browser_visible?
|
|
83
|
-
visible = false if image_viewer_active?
|
|
82
|
+
visible = (project_browser_search_active? || project_browser_name_entry_active?) if project_browser_visible?
|
|
83
|
+
visible = false if image_viewer_active? || editor_runner_output_visible?
|
|
84
84
|
set_cursor_visible_locked(visible)
|
|
85
85
|
end
|
|
86
86
|
|
|
@@ -338,6 +338,7 @@ module Kward
|
|
|
338
338
|
|
|
339
339
|
@mutex.synchronize do
|
|
340
340
|
@busy = true
|
|
341
|
+
@busy_started_at = monotonic_now
|
|
341
342
|
@busy_activity = normalize_busy_activity(activity)
|
|
342
343
|
@asking = true
|
|
343
344
|
reset_spinner_locked
|
|
@@ -349,7 +350,8 @@ module Kward
|
|
|
349
350
|
def finish_select_action
|
|
350
351
|
@mutex.synchronize do
|
|
351
352
|
@busy = false
|
|
352
|
-
@
|
|
353
|
+
@busy_started_at = nil
|
|
354
|
+
@busy_activity = "thinking"
|
|
353
355
|
@select_state&.delete(:busy_activity)
|
|
354
356
|
render_prompt_locked if @asking
|
|
355
357
|
flush_output_locked
|
|
@@ -19,13 +19,17 @@ module Kward
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def normalize_slash_commands(commands)
|
|
22
|
-
commands.
|
|
22
|
+
commands.filter_map do |command|
|
|
23
|
+
name = slash_command_value(command, :name).to_s
|
|
24
|
+
next if name.empty?
|
|
25
|
+
|
|
23
26
|
{
|
|
24
|
-
name:
|
|
27
|
+
name: name,
|
|
28
|
+
normalized_name: name.downcase,
|
|
25
29
|
description: slash_command_value(command, :description).to_s,
|
|
26
30
|
argument_hint: slash_command_value(command, :argument_hint).to_s
|
|
27
31
|
}
|
|
28
|
-
end.
|
|
32
|
+
end.sort_by { |command| command[:name] }
|
|
29
33
|
end
|
|
30
34
|
|
|
31
35
|
def slash_command_value(command, key)
|
|
@@ -44,7 +48,18 @@ module Kward
|
|
|
44
48
|
|
|
45
49
|
def slash_overlay_matches
|
|
46
50
|
prefix = composer_input.delete_prefix("/").downcase
|
|
47
|
-
|
|
51
|
+
cache = @slash_overlay_match_cache
|
|
52
|
+
return cache[:matches] if cache && cache[:prefix] == prefix && cache[:commands].equal?(@slash_commands)
|
|
53
|
+
|
|
54
|
+
matches = []
|
|
55
|
+
@slash_commands.each do |command|
|
|
56
|
+
next unless command[:normalized_name].start_with?(prefix)
|
|
57
|
+
|
|
58
|
+
matches << command
|
|
59
|
+
break if matches.length == 8
|
|
60
|
+
end
|
|
61
|
+
@slash_overlay_match_cache = { prefix: prefix, commands: @slash_commands, matches: matches }
|
|
62
|
+
matches
|
|
48
63
|
end
|
|
49
64
|
|
|
50
65
|
def selected_slash_command
|