kward 0.82.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -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/agent-tools.md +1 -0
  9. data/doc/api.md +4 -0
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +82 -20
  12. data/doc/editor.md +47 -22
  13. data/doc/files.md +12 -7
  14. data/doc/getting-started.md +3 -0
  15. data/doc/pan.md +19 -15
  16. data/doc/permissions.md +1 -0
  17. data/doc/platform-support.md +48 -0
  18. data/doc/releasing.md +12 -4
  19. data/doc/rpc.md +2 -2
  20. data/doc/sandboxing.md +5 -1
  21. data/doc/security.md +4 -3
  22. data/doc/shell.md +101 -60
  23. data/doc/tabs.md +3 -0
  24. data/doc/troubleshooting.md +12 -2
  25. data/doc/usage.md +7 -6
  26. data/kward.gemspec +5 -4
  27. data/lib/kward/agent.rb +7 -3
  28. data/lib/kward/ansi.rb +110 -10
  29. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  30. data/lib/kward/cli/auth_commands.rb +34 -13
  31. data/lib/kward/cli/commands.rb +83 -62
  32. data/lib/kward/cli/doctor.rb +39 -17
  33. data/lib/kward/cli/hook_commands.rb +22 -12
  34. data/lib/kward/cli/interactive_turn.rb +144 -21
  35. data/lib/kward/cli/project_skills_commands.rb +8 -4
  36. data/lib/kward/cli/prompt_interface.rb +27 -0
  37. data/lib/kward/cli/rendering.rb +15 -9
  38. data/lib/kward/cli/runtime_helpers.rb +260 -55
  39. data/lib/kward/cli/slash_commands.rb +21 -12
  40. data/lib/kward/cli/tabs.rb +91 -13
  41. data/lib/kward/cli/tool_summaries.rb +14 -0
  42. data/lib/kward/cli.rb +55 -7
  43. data/lib/kward/cli_transcript_formatter.rb +11 -4
  44. data/lib/kward/compaction/token_estimator.rb +12 -6
  45. data/lib/kward/config_files.rb +94 -63
  46. data/lib/kward/detached_run.rb +44 -0
  47. data/lib/kward/editor_prompt.rb +46 -0
  48. data/lib/kward/editor_prompt_session.rb +28 -0
  49. data/lib/kward/interactive_pty_runner.rb +102 -28
  50. data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
  51. data/lib/kward/kwshrc.rb +233 -0
  52. data/lib/kward/markdown_code_block.rb +136 -0
  53. data/lib/kward/model/client.rb +35 -39
  54. data/lib/kward/model/model_info.rb +3 -2
  55. data/lib/kward/model/payloads.rb +0 -2
  56. data/lib/kward/model/provider_catalog.rb +5 -0
  57. data/lib/kward/model/stream_parser.rb +20 -4
  58. data/lib/kward/pan/index.html.erb +3 -3
  59. data/lib/kward/pan/server.rb +23 -3
  60. data/lib/kward/persistent_shell_session.rb +843 -0
  61. data/lib/kward/project_files.rb +19 -6
  62. data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
  63. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  64. data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
  65. data/lib/kward/prompt_interface/editor/controller.rb +99 -33
  66. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  67. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  68. data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
  69. data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
  70. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  71. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/state.rb +21 -10
  73. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  74. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  75. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  76. data/lib/kward/prompt_interface/file_overlay.rb +92 -22
  77. data/lib/kward/prompt_interface/key_handler.rb +75 -0
  78. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  79. data/lib/kward/prompt_interface/project_browser.rb +443 -17
  80. data/lib/kward/prompt_interface/runtime_state.rb +55 -2
  81. data/lib/kward/prompt_interface/screen.rb +2 -2
  82. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  83. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  84. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  85. data/lib/kward/prompt_interface.rb +169 -18
  86. data/lib/kward/prompts/commands.rb +2 -1
  87. data/lib/kward/prompts.rb +1 -1
  88. data/lib/kward/pty_output_sink.rb +47 -0
  89. data/lib/kward/rpc/auth_manager.rb +1 -1
  90. data/lib/kward/rpc/server.rb +2 -1
  91. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  92. data/lib/kward/scratchpad_languages.rb +74 -0
  93. data/lib/kward/scratchpad_runner.rb +155 -29
  94. data/lib/kward/shell_prompt.rb +52 -0
  95. data/lib/kward/shell_prompt_session.rb +58 -0
  96. data/lib/kward/terminal_keys.rb +13 -0
  97. data/lib/kward/terminal_text.rb +121 -0
  98. data/lib/kward/text_matcher.rb +18 -0
  99. data/lib/kward/tools/open_editor.rb +41 -0
  100. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  101. data/lib/kward/tools/registry.rb +75 -8
  102. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  103. data/lib/kward/tools/run_shell_command.rb +24 -6
  104. data/lib/kward/tools/tool_call.rb +2 -1
  105. data/lib/kward/version.rb +1 -1
  106. data/templates/default/fulldoc/html/css/kward.css +0 -125
  107. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  108. data/templates/default/fulldoc/html/setup.rb +1 -1
  109. data/templates/default/kward_navigation.rb +1 -0
  110. data/templates/default/layout/html/footer.erb +10 -0
  111. data/templates/default/layout/html/headers.erb +23 -0
  112. data/templates/default/layout/html/layout.erb +6 -18
  113. data/templates/default/layout/html/setup.rb +41 -2
  114. metadata +44 -8
  115. 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"
@@ -48,15 +49,18 @@ module Kward
48
49
  end
49
50
 
50
51
  def open_project_browser_locked
51
- paths = project_file_paths
52
52
  saved_state = saved_project_browser_state
53
+ show_ignored = saved_state["show_ignored"] == true
54
+ paths = project_file_paths(include_ignored: show_ignored)
53
55
  @project_browser_state = {
54
56
  paths: paths,
57
+ show_ignored: show_ignored,
55
58
  expanded: restored_project_browser_expanded_paths(paths, saved_state),
56
59
  selection_index: 0,
57
60
  search_active: false,
58
61
  query: "",
59
- status: nil
62
+ status: nil,
63
+ directory_paths: restored_project_browser_directory_paths(saved_state)
60
64
  }
61
65
  restore_project_browser_selection(saved_state["selected_path"])
62
66
  self.composer_input = ""
@@ -84,6 +88,8 @@ module Kward
84
88
  end
85
89
 
86
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?
87
93
  return true if handle_bundled_key(key) { |token| handle_project_browser_key(token) }
88
94
 
89
95
  csi_result = handle_project_browser_csi_u_key(key)
@@ -93,7 +99,7 @@ module Kward
93
99
  when :return, :enter
94
100
  open_or_toggle_selected_project_browser_row
95
101
  when :backspace
96
- project_browser_delete_search_character
102
+ project_browser_search_active? ? project_browser_delete_search_character : request_project_browser_delete
97
103
  when :ctrl_l
98
104
  redraw_screen_locked
99
105
  when :left
@@ -121,7 +127,7 @@ module Kward
121
127
  when 27
122
128
  project_browser_escape
123
129
  when 8, 127
124
- project_browser_delete_search_character
130
+ project_browser_search_active? ? project_browser_delete_search_character : request_project_browser_delete
125
131
  when 9
126
132
  return false if ctrl_modifier?(sequence[:modifier]) || alt_modifier?(sequence[:modifier]) || super_modifier?(sequence[:modifier])
127
133
 
@@ -142,6 +148,10 @@ module Kward
142
148
  collapse_selected_project_browser_row
143
149
  elsif text == "l" && !project_browser_search_active?
144
150
  expand_selected_project_browser_row
151
+ elsif text == "i" && !project_browser_search_active?
152
+ toggle_project_browser_ignored_files
153
+ elsif ["f", "r", "d"].include?(text) && !project_browser_search_active?
154
+ start_project_browser_name_entry(text)
145
155
  elsif project_browser_search_active?
146
156
  project_browser_append_search(text)
147
157
  else
@@ -156,7 +166,7 @@ module Kward
156
166
  when "\n", "\r"
157
167
  open_or_toggle_selected_project_browser_row
158
168
  when "\b", "\x7F"
159
- project_browser_delete_search_character
169
+ project_browser_search_active? ? project_browser_delete_search_character : request_project_browser_delete
160
170
  when "\e"
161
171
  project_browser_escape
162
172
  when "@"
@@ -173,6 +183,10 @@ module Kward
173
183
  project_browser_search_active? ? project_browser_append_search(key) : collapse_selected_project_browser_row
174
184
  when "l"
175
185
  project_browser_search_active? ? project_browser_append_search(key) : expand_selected_project_browser_row
186
+ when "i"
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?
176
190
  else
177
191
  project_browser_append_search(key) if project_browser_search_active? && printable_key?(key)
178
192
  end
@@ -186,10 +200,390 @@ module Kward
186
200
  end
187
201
  end
188
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
+
189
568
  def toggle_project_browser_search
190
569
  project_browser_search_active? ? deactivate_project_browser_search : activate_project_browser_search
191
570
  end
192
571
 
572
+ def toggle_project_browser_ignored_files
573
+ selected_path = selected_project_browser_row&.[](:path)
574
+ show_ignored = !project_browser_ignored_files_visible?
575
+ @project_browser_state[:paths] = project_file_paths(include_ignored: show_ignored)
576
+ @project_browser_state[:show_ignored] = show_ignored
577
+ @project_browser_tree_paths = nil
578
+ @project_browser_tree = nil
579
+ restore_project_browser_selection(selected_path)
580
+ persist_project_browser_state
581
+ end
582
+
583
+ def project_browser_ignored_files_visible?
584
+ @project_browser_state[:show_ignored] == true
585
+ end
586
+
193
587
  def activate_project_browser_search
194
588
  @project_browser_state[:search_active] = true
195
589
  @project_browser_state[:query] = ""
@@ -468,15 +862,18 @@ module Kward
468
862
 
469
863
  @project_browser_restore_after_editor = false
470
864
  unless @project_browser_state
471
- paths = project_file_paths
472
865
  saved_state = saved_project_browser_state
866
+ show_ignored = saved_state["show_ignored"] == true
867
+ paths = project_file_paths(include_ignored: show_ignored)
473
868
  @project_browser_state = {
474
869
  paths: paths,
870
+ show_ignored: show_ignored,
475
871
  expanded: restored_project_browser_expanded_paths(paths, saved_state),
476
872
  selection_index: 0,
477
873
  search_active: false,
478
874
  query: "",
479
- status: nil
875
+ status: nil,
876
+ directory_paths: restored_project_browser_directory_paths(saved_state)
480
877
  }
481
878
  restore_project_browser_selection(saved_state["selected_path"])
482
879
  end
@@ -486,6 +883,7 @@ module Kward
486
883
 
487
884
  def project_browser_rows(width, height: screen_height)
488
885
  return [] unless project_browser_visible?
886
+ return project_browser_delete_confirmation_rows(width) if project_browser_delete_confirmation_active?
489
887
 
490
888
  rows = project_browser_visible_rows
491
889
  lines = []
@@ -507,7 +905,8 @@ module Kward
507
905
 
508
906
  def project_browser_title
509
907
  query = @project_browser_state[:query].to_s
510
- project_browser_search_active? ? "Project files — Search: #{query}" : "Project files"
908
+ title = project_browser_search_active? ? "Project files — Search: #{query}" : "Project files"
909
+ project_browser_ignored_files_visible? ? "#{title} (including ignored)" : title
511
910
  end
512
911
 
513
912
  def project_browser_empty_message
@@ -518,7 +917,7 @@ module Kward
518
917
  if project_browser_search_active?
519
918
  "Type search • Esc tree • Enter open • @ mention"
520
919
  else
521
- "Enter open/toggle • ←/→ 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"
522
921
  end
523
922
  end
524
923
 
@@ -555,14 +954,20 @@ module Kward
555
954
 
556
955
  def project_browser_search_rows
557
956
  query = @project_browser_state[:query].downcase
558
- matches = []
559
- project_file_path_entries.each do |entry|
560
- 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)
561
960
 
562
- matches << { path: entry[:path], name: entry[:path], depth: 0, directory: false }
563
- 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
564
968
  end
565
- matches
969
+ @project_browser_search_cache = { query: query, entries: entries, rows: rows }
970
+ rows
566
971
  end
567
972
 
568
973
  def project_browser_tree
@@ -571,6 +976,15 @@ module Kward
571
976
 
572
977
  directories = Hash.new { |hash, key| hash[key] = Set.new }
573
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
574
988
  paths.each do |path|
575
989
  parts = path.split("/")
576
990
  parent = PROJECT_BROWSER_ROOT
@@ -631,7 +1045,9 @@ module Kward
631
1045
  row = selected_project_browser_row
632
1046
  workspaces[project_browser_workspace_root] = {
633
1047
  "expanded" => @project_browser_state[:expanded].to_a.sort,
634
- "selected_path" => row && row[:path]
1048
+ "selected_path" => row && row[:path],
1049
+ "show_ignored" => project_browser_ignored_files_visible?,
1050
+ "created_directories" => @project_browser_state.fetch(:directory_paths, Set.new).to_a.sort
635
1051
  }
636
1052
  data["version"] = PROJECT_BROWSER_STATE_VERSION
637
1053
  data["workspaces"] = workspaces
@@ -654,8 +1070,18 @@ module Kward
654
1070
  ConfigFiles.canonical_workspace_root(prompt_workspace_root)
655
1071
  end
656
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
+
657
1083
  def restored_project_browser_expanded_paths(paths, saved_state)
658
- directories = project_browser_directory_paths(paths)
1084
+ directories = project_browser_directory_paths(paths) | restored_project_browser_directory_paths(saved_state)
659
1085
  saved_expanded = saved_state["expanded"]
660
1086
  expanded = if saved_expanded.is_a?(Array)
661
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?
@@ -118,8 +167,12 @@ module Kward
118
167
  result.is_a?(Hash) && result[:reasoning_action]
119
168
  end
120
169
 
170
+ def editor_prompt_action?(result)
171
+ result.is_a?(Hash) && result[:editor_prompt]
172
+ end
173
+
121
174
  def prompt_action_result?(result)
122
- tab_action_result?(result) || reasoning_action_result?(result)
175
+ tab_action_result?(result) || reasoning_action_result?(result) || editor_prompt_action?(result)
123
176
  end
124
177
 
125
178
  end
@@ -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