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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/api.md +4 -0
  9. data/doc/composer.md +1 -1
  10. data/doc/configuration.md +68 -21
  11. data/doc/editor.md +28 -13
  12. data/doc/files.md +8 -4
  13. data/doc/getting-started.md +3 -0
  14. data/doc/pan.md +19 -15
  15. data/doc/platform-support.md +48 -0
  16. data/doc/security.md +3 -2
  17. data/doc/shell.md +62 -45
  18. data/doc/troubleshooting.md +12 -2
  19. data/doc/usage.md +5 -5
  20. data/kward.gemspec +5 -4
  21. data/lib/kward/agent.rb +6 -3
  22. data/lib/kward/ansi.rb +110 -10
  23. data/lib/kward/cli/auth_commands.rb +34 -13
  24. data/lib/kward/cli/commands.rb +83 -62
  25. data/lib/kward/cli/doctor.rb +39 -17
  26. data/lib/kward/cli/hook_commands.rb +22 -12
  27. data/lib/kward/cli/interactive_turn.rb +48 -7
  28. data/lib/kward/cli/project_skills_commands.rb +8 -4
  29. data/lib/kward/cli/prompt_interface.rb +27 -0
  30. data/lib/kward/cli/rendering.rb +15 -9
  31. data/lib/kward/cli/runtime_helpers.rb +118 -55
  32. data/lib/kward/cli/slash_commands.rb +12 -14
  33. data/lib/kward/cli/tabs.rb +83 -12
  34. data/lib/kward/cli/tool_summaries.rb +14 -0
  35. data/lib/kward/cli.rb +45 -7
  36. data/lib/kward/cli_transcript_formatter.rb +11 -4
  37. data/lib/kward/config_files.rb +82 -68
  38. data/lib/kward/detached_run.rb +44 -0
  39. data/lib/kward/interactive_pty_runner.rb +102 -28
  40. data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
  41. data/lib/kward/kwshrc.rb +233 -0
  42. data/lib/kward/markdown_code_block.rb +136 -0
  43. data/lib/kward/model/client.rb +34 -22
  44. data/lib/kward/model/provider_catalog.rb +5 -0
  45. data/lib/kward/model/stream_parser.rb +20 -4
  46. data/lib/kward/pan/index.html.erb +3 -3
  47. data/lib/kward/pan/server.rb +23 -3
  48. data/lib/kward/persistent_shell_session.rb +119 -26
  49. data/lib/kward/project_files.rb +2 -2
  50. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  51. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  52. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  53. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  54. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  55. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  56. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  57. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  58. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  59. data/lib/kward/prompt_interface/editor/state.rb +10 -10
  60. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  61. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  62. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  63. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  64. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  65. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  66. data/lib/kward/prompt_interface/runtime_state.rb +50 -1
  67. data/lib/kward/prompt_interface/screen.rb +2 -2
  68. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  69. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  70. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  71. data/lib/kward/prompt_interface.rb +93 -18
  72. data/lib/kward/prompts/commands.rb +1 -1
  73. data/lib/kward/prompts.rb +1 -1
  74. data/lib/kward/pty_output_sink.rb +47 -0
  75. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  76. data/lib/kward/scratchpad_languages.rb +74 -0
  77. data/lib/kward/scratchpad_runner.rb +155 -29
  78. data/lib/kward/shell_prompt.rb +2 -0
  79. data/lib/kward/terminal_keys.rb +12 -0
  80. data/lib/kward/terminal_text.rb +121 -0
  81. data/lib/kward/text_matcher.rb +18 -0
  82. data/lib/kward/tools/open_editor.rb +41 -0
  83. data/lib/kward/tools/registry.rb +13 -4
  84. data/lib/kward/tools/tool_call.rb +2 -1
  85. data/lib/kward/version.rb +1 -1
  86. data/templates/default/fulldoc/html/css/kward.css +0 -125
  87. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  88. data/templates/default/fulldoc/html/setup.rb +1 -1
  89. data/templates/default/kward_navigation.rb +1 -0
  90. data/templates/default/layout/html/footer.erb +10 -0
  91. data/templates/default/layout/html/headers.erb +23 -0
  92. data/templates/default/layout/html/layout.erb +6 -18
  93. data/templates/default/layout/html/setup.rb +41 -2
  94. metadata +36 -8
  95. 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
- matches = []
583
- project_file_path_entries.each do |entry|
584
- next unless file_mention_match?(entry[:downcase], query)
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
- matches << { path: entry[:path], name: entry[:path], depth: 0, directory: false }
587
- break if matches.length >= PROJECT_BROWSER_RESULT_LIMIT
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
- matches
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? ? "streaming" : text
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
- @busy_activity = "streaming"
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.map do |command|
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: slash_command_value(command, :name).to_s,
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.reject { |command| command[:name].empty? }.sort_by { |command| command[:name] }
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
- @slash_commands.select { |command| command[:name].downcase.start_with?(prefix) }.first(8)
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