kward 0.82.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Gemfile.lock +2 -2
  4. data/doc/agent-tools.md +1 -0
  5. data/doc/composer.md +1 -1
  6. data/doc/configuration.md +18 -3
  7. data/doc/editor.md +20 -10
  8. data/doc/files.md +4 -3
  9. data/doc/permissions.md +1 -0
  10. data/doc/releasing.md +12 -4
  11. data/doc/rpc.md +2 -2
  12. data/doc/sandboxing.md +5 -1
  13. data/doc/security.md +1 -1
  14. data/doc/shell.md +40 -16
  15. data/doc/tabs.md +3 -0
  16. data/doc/usage.md +3 -2
  17. data/lib/kward/agent.rb +1 -0
  18. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  19. data/lib/kward/cli/interactive_turn.rb +101 -19
  20. data/lib/kward/cli/runtime_helpers.rb +156 -14
  21. data/lib/kward/cli/slash_commands.rb +12 -1
  22. data/lib/kward/cli/tabs.rb +12 -5
  23. data/lib/kward/cli.rb +10 -0
  24. data/lib/kward/compaction/token_estimator.rb +12 -6
  25. data/lib/kward/config_files.rb +17 -0
  26. data/lib/kward/editor_prompt.rb +46 -0
  27. data/lib/kward/editor_prompt_session.rb +28 -0
  28. data/lib/kward/ekwsh.rb +70 -10
  29. data/lib/kward/model/client.rb +1 -17
  30. data/lib/kward/model/model_info.rb +3 -2
  31. data/lib/kward/model/payloads.rb +0 -2
  32. data/lib/kward/persistent_shell_session.rb +750 -0
  33. data/lib/kward/project_files.rb +18 -5
  34. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  35. data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
  36. data/lib/kward/prompt_interface/editor/controller.rb +69 -0
  37. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  38. data/lib/kward/prompt_interface/editor/modes/vibe.rb +10 -4
  39. data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
  40. data/lib/kward/prompt_interface/editor/state.rb +11 -0
  41. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  42. data/lib/kward/prompt_interface/file_overlay.rb +21 -7
  43. data/lib/kward/prompt_interface/key_handler.rb +8 -0
  44. data/lib/kward/prompt_interface/project_browser.rb +30 -5
  45. data/lib/kward/prompt_interface/runtime_state.rb +5 -1
  46. data/lib/kward/prompt_interface.rb +76 -0
  47. data/lib/kward/prompts/commands.rb +1 -0
  48. data/lib/kward/rpc/auth_manager.rb +1 -1
  49. data/lib/kward/rpc/server.rb +2 -1
  50. data/lib/kward/shell_prompt.rb +50 -0
  51. data/lib/kward/shell_prompt_session.rb +58 -0
  52. data/lib/kward/terminal_keys.rb +1 -0
  53. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  54. data/lib/kward/tools/registry.rb +63 -5
  55. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  56. data/lib/kward/tools/run_shell_command.rb +24 -6
  57. data/lib/kward/version.rb +1 -1
  58. data/templates/default/layout/html/layout.erb +1 -1
  59. metadata +9 -1
@@ -8,21 +8,34 @@ module Kward
8
8
  module ProjectFiles
9
9
  module_function
10
10
 
11
- def list(root: Dir.pwd)
12
- paths = git_paths(root)
13
- paths = scanned_paths(root) if paths.empty?
11
+ def list(root: Dir.pwd, include_ignored: false)
12
+ paths = git_paths(root, include_ignored: include_ignored)
13
+ paths = scanned_paths(root) if paths.empty? && !git_repository?(root)
14
14
  paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
15
15
  end
16
16
 
17
- def git_paths(root)
17
+ def git_paths(root, include_ignored: false)
18
18
  output, status = Open3.capture2("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
19
19
  return [] unless status.success?
20
20
 
21
- output.lines.map(&:chomp).reject(&:empty?)
21
+ paths = output.lines.map(&:chomp).reject(&:empty?)
22
+ return paths unless include_ignored
23
+
24
+ ignored_output, ignored_status = Open3.capture2("git", "ls-files", "--others", "--ignored", "--exclude-standard", chdir: root)
25
+ return paths unless ignored_status.success?
26
+
27
+ paths + ignored_output.lines.map(&:chomp).reject(&:empty?)
22
28
  rescue StandardError
23
29
  []
24
30
  end
25
31
 
32
+ def git_repository?(root)
33
+ _output, status = Open3.capture2e("git", "rev-parse", "--is-inside-work-tree", chdir: root)
34
+ status.success?
35
+ rescue StandardError
36
+ false
37
+ end
38
+
26
39
  def scanned_paths(root)
27
40
  root_path = Pathname.new(root)
28
41
  paths = []
@@ -8,7 +8,7 @@ module Kward
8
8
 
9
9
  def composer_layout(width, height = screen_height)
10
10
  return interactive_layout(width, height) if interactive_active_locked?
11
- return editor_layout(width, height) if editor_active?
11
+ return editor_layout(width, height) if editor_visible?
12
12
  return compact_composer_layout(width) if height < 4
13
13
  return question_composer_layout(width, height) if @question_state
14
14
 
@@ -83,9 +83,12 @@ module Kward
83
83
 
84
84
  def editor_insert_tab
85
85
  if @editor_state.multi_cursor? || @editor_state.selection_ranges.any?
86
+ reset_editor_word_completion
86
87
  return @editor_state.replace_selections(editor_indent_unit)
87
88
  end
88
89
 
90
+ return true if editor_complete_buffer_word
91
+
89
92
  if current_editor_auto_indent? && editor_cursor_in_leading_indent?
90
93
  return editor_smart_tab_forward
91
94
  end
@@ -13,6 +13,10 @@ module Kward
13
13
  !@editor_state.nil?
14
14
  end
15
15
 
16
+ def editor_visible?
17
+ editor_active?
18
+ end
19
+
16
20
  def open_selected_file_in_editor(fallback_to_typed_path: false)
17
21
  path = selected_file_open_path
18
22
  if path
@@ -58,6 +62,7 @@ module Kward
58
62
  language: language
59
63
  )
60
64
  @editor_state.status = scratchpad_status_text(language)
65
+ @editor_agent_suspended = false
61
66
  @prompt_label = "Edit>"
62
67
  self.composer_input = ""
63
68
  self.composer_cursor = 0
@@ -95,6 +100,7 @@ module Kward
95
100
  end
96
101
 
97
102
  @editor_state = EditorState.new(path: full_path, content: File.exist?(full_path) ? File.read(full_path) : "", new_file: !File.exist?(full_path), editor_mode: current_editor_mode)
103
+ @editor_agent_suspended = false
98
104
  @prompt_label = "Edit>"
99
105
  self.composer_input = ""
100
106
  self.composer_cursor = 0
@@ -115,6 +121,7 @@ module Kward
115
121
  diff_view_mode = current_diff_view_mode
116
122
  viewer_content = diff_view_mode == DiffViewMode::SIDE_BY_SIDE ? side_by_side_diff_content(content.to_s) : content.to_s
117
123
  @editor_state = EditorState.new(path: path.to_s, content: viewer_content, new_file: true, editor_mode: current_editor_mode, readonly: true, diff_view: diff_view_mode)
124
+ @editor_agent_suspended = false
118
125
  @prompt_label = "Diff>"
119
126
  self.composer_input = ""
120
127
  self.composer_cursor = 0
@@ -243,6 +250,7 @@ module Kward
243
250
  @editor_save_as_buffer = ""
244
251
  @editor_save_callback = nil
245
252
  @editor_state = nil
253
+ @editor_agent_suspended = false
246
254
  @prompt_label = "You>"
247
255
  self.composer_input = ""
248
256
  self.composer_cursor = 0
@@ -252,6 +260,9 @@ module Kward
252
260
 
253
261
  def handle_editor_key(key)
254
262
  return if key.nil?
263
+ return handle_editor_prompt_key(key) if @editor_prompt_active
264
+
265
+ reset_editor_word_completion unless editor_word_completion_tab_key?(key) && !editor_search_active?
255
266
  mouse_result = handle_editor_mouse_key(key)
256
267
  return mouse_result unless mouse_result == false
257
268
  return handle_editor_save_as_key(key) if @editor_save_as_active
@@ -312,6 +323,64 @@ module Kward
312
323
  end
313
324
  end
314
325
 
326
+ def editor_prompt_action(instruction)
327
+ {
328
+ editor_prompt: {
329
+ instruction: instruction.to_s.strip,
330
+ display_input: ":prompt #{instruction.to_s.strip}"
331
+ }
332
+ }
333
+ end
334
+
335
+ def begin_editor_prompt
336
+ @editor_prompt_active = true
337
+ @editor_prompt_input = ""
338
+ @editor_state.status = "Prompt: "
339
+ true
340
+ end
341
+
342
+ def handle_editor_prompt_key(key)
343
+ sequence = parse_csi_u_key(key)
344
+ if sequence
345
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
346
+ return handle_editor_prompt_key("\r") if sequence[:code] == 13
347
+ return handle_editor_prompt_key("\e") if sequence[:code] == 27
348
+ return handle_editor_prompt_key("\b") if [8, 127].include?(sequence[:code])
349
+
350
+ text = csi_u_printable_text(sequence)
351
+ return handle_editor_prompt_key(text) if text
352
+
353
+ return true
354
+ end
355
+
356
+ case key
357
+ when "\e", TerminalKeys::CTRL_C, :escape
358
+ @editor_prompt_active = false
359
+ @editor_prompt_input = ""
360
+ @editor_state.status = EditorStatusText.default(readonly: @editor_state.readonly?, editor_mode: @editor_state.editor_mode)
361
+ true
362
+ when "\b", "\x7F"
363
+ @editor_prompt_input = @editor_prompt_input[0...-1]
364
+ @editor_state.status = "Prompt: #{@editor_prompt_input}"
365
+ when "\n", "\r"
366
+ instruction = @editor_prompt_input.strip
367
+ @editor_prompt_active = false
368
+ @editor_prompt_input = ""
369
+ if instruction.empty?
370
+ @editor_state.status = "Prompt instruction required"
371
+ true
372
+ else
373
+ editor_prompt_action(instruction)
374
+ end
375
+ else
376
+ if printable_key?(key)
377
+ @editor_prompt_input += key
378
+ @editor_state.status = "Prompt: #{@editor_prompt_input}"
379
+ end
380
+ true
381
+ end
382
+ end
383
+
315
384
  def handle_editor_csi_u_key(key)
316
385
  sequence = parse_csi_u_key(key)
317
386
  return false unless sequence
@@ -198,6 +198,8 @@ module Kward
198
198
 
199
199
  def handle_modern_key_binding(key)
200
200
  case key
201
+ when TerminalKeys::CTRL_PERIOD
202
+ begin_editor_prompt
201
203
  when TerminalKeys::CTRL_SPACE
202
204
  true
203
205
  when TerminalKeys::CTRL_C
@@ -221,6 +223,8 @@ module Kward
221
223
  end
222
224
 
223
225
  case normalized_code
226
+ when 46
227
+ begin_editor_prompt
224
228
  when 13
225
229
  return false if editor_search_active?
226
230
 
@@ -192,6 +192,7 @@ module Kward
192
192
  end
193
193
 
194
194
  def handle_vibe_command_key(key)
195
+ result = nil
195
196
  case key
196
197
  when "\e", TerminalKeys::CTRL_C, :escape
197
198
  @editor_state.vibe_command = ""
@@ -202,20 +203,21 @@ module Kward
202
203
  when "\t"
203
204
  vibe_complete_command_path
204
205
  when "\n", "\r"
205
- execute_vibe_command(@editor_state.vibe_command)
206
+ result = execute_vibe_command(@editor_state.vibe_command)
206
207
  else
207
208
  if printable_key?(key)
208
209
  @editor_state.vibe_command = @editor_state.vibe_command.to_s + key
209
210
  @editor_state.status = ":#{@editor_state.vibe_command}"
210
211
  end
211
212
  end
212
- true
213
+ result || true
213
214
  end
214
215
 
215
216
  def execute_vibe_command(command)
216
217
  command = command.to_s.strip
217
218
  @editor_state.vibe_mode = "normal"
218
219
  @editor_state.vibe_command = ""
220
+ action = nil
219
221
  case command
220
222
  when "w"
221
223
  save_editor
@@ -225,6 +227,10 @@ module Kward
225
227
  vibe_edit_file(Regexp.last_match(2), force: Regexp.last_match(1) == "!")
226
228
  when "run"
227
229
  vibe_record_undo { run_editor_buffer }
230
+ when /\Aprompt\s+(.+)\z/
231
+ action = editor_prompt_action(Regexp.last_match(1))
232
+ when "prompt"
233
+ @editor_state.status = "Prompt instruction required"
228
234
  when "q"
229
235
  vibe_quit_editor
230
236
  when "q!"
@@ -244,7 +250,7 @@ module Kward
244
250
  else
245
251
  @editor_state.status = "Unknown command: #{command}"
246
252
  end
247
- true
253
+ action || true
248
254
  end
249
255
 
250
256
  def vibe_edit_file(path, force: false)
@@ -426,7 +432,7 @@ module Kward
426
432
  def vibe_return_to_normal
427
433
  vibe_apply_visual_block_insert if @editor_state.vibe_visual_block_insert
428
434
  @editor_state.vibe_mode = "normal"
429
- @editor_state.status = "NORMAL · i insert · :w save · :q quit"
435
+ @editor_state.status = "NORMAL · i insert · :prompt ask agent · :w save · :q quit"
430
436
  true
431
437
  end
432
438
 
@@ -264,6 +264,7 @@ module Kward
264
264
 
265
265
  def editor_status_text
266
266
  text = @editor_state.search_active ? "#{@editor_state.search_direction == :backward ? "Search backward" : "Search"}: #{@editor_state.search_query}" : @editor_state.status
267
+ text = "#{text} · #{spinner_frame}" if @editor_agent_suspended && @busy
267
268
  visible_truncate(text, [screen_width - 4, 1].max)
268
269
  end
269
270
  end
@@ -830,6 +830,17 @@ module Kward
830
830
  @cursor = @buffer.length
831
831
  end
832
832
 
833
+ def replace_buffer(content)
834
+ content = content.to_s
835
+ return false if content == @buffer
836
+
837
+ push_undo
838
+ self.buffer = content
839
+ @cursor = [@cursor, @buffer.length].min
840
+ changed!
841
+ true
842
+ end
843
+
833
844
  def replace_range(start_index, end_index, text)
834
845
  start_index, = @text_buffer.replace_range(start_index, end_index, text)
835
846
  @buffer = @text_buffer.text
@@ -0,0 +1,124 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Interactive terminal UI used by the CLI frontend.
4
+ class PromptInterface
5
+ # Current-buffer word completion for the built-in composer file editor.
6
+ module EditorWordCompletion
7
+ EDITOR_COMPLETION_WORD_PATTERN = /[[:alnum:]_]+/.freeze
8
+ EDITOR_COMPLETION_PREFIX_PATTERN = /[[:alnum:]_]+\z/.freeze
9
+ EDITOR_COMPLETION_SUFFIX_PATTERN = /\A[[:alnum:]_]*/.freeze
10
+
11
+ private
12
+
13
+ def editor_complete_buffer_word
14
+ if (cycle = matching_editor_completion_cycle)
15
+ return cycle_editor_word_completion(cycle) if cycle[:candidates].length > 1
16
+
17
+ reset_editor_word_completion
18
+ end
19
+
20
+ context = editor_word_completion_context
21
+ return false unless context
22
+
23
+ candidates = editor_word_completion_candidates(context)
24
+ return false if candidates.empty?
25
+
26
+ @editor_word_completion = context.merge(
27
+ editor_state: @editor_state,
28
+ candidates: candidates,
29
+ index: 0,
30
+ previous_status: @editor_state.status
31
+ )
32
+ apply_editor_word_completion(@editor_word_completion)
33
+ true
34
+ end
35
+
36
+ def editor_word_completion_context
37
+ buffer = @editor_state.buffer
38
+ cursor = @editor_state.cursor
39
+ prefix_match = buffer[0...cursor].to_s.match(EDITOR_COMPLETION_PREFIX_PATTERN)
40
+ return nil unless prefix_match
41
+
42
+ suffix = buffer[cursor..].to_s.match(EDITOR_COMPLETION_SUFFIX_PATTERN).to_s
43
+ {
44
+ prefix: prefix_match[0],
45
+ range_start: prefix_match.begin(0),
46
+ range_end: cursor + suffix.length
47
+ }
48
+ end
49
+
50
+ def editor_word_completion_candidates(context)
51
+ matches = []
52
+ @editor_state.buffer.to_enum(:scan, EDITOR_COMPLETION_WORD_PATTERN).each do
53
+ match = Regexp.last_match
54
+ next if editor_completion_match_overlaps_context?(match, context)
55
+
56
+ word = match[0]
57
+ next unless word.length > context[:prefix].length
58
+ next unless word.start_with?(context[:prefix])
59
+
60
+ matches << [word, match.begin(0)]
61
+ end
62
+
63
+ matches
64
+ .sort_by { |_word, offset| editor_completion_candidate_order(offset, context) }
65
+ .map(&:first)
66
+ .uniq
67
+ end
68
+
69
+ def editor_completion_match_overlaps_context?(match, context)
70
+ match.begin(0) < context[:range_end] && match.end(0) > context[:range_start]
71
+ end
72
+
73
+ def editor_completion_candidate_order(offset, context)
74
+ if offset < context[:range_start]
75
+ [0, context[:range_start] - offset]
76
+ else
77
+ [1, offset - context[:range_end]]
78
+ end
79
+ end
80
+
81
+ def matching_editor_completion_cycle
82
+ cycle = @editor_word_completion
83
+ return nil unless cycle
84
+
85
+ same_editor = cycle[:editor_state].equal?(@editor_state)
86
+ same_buffer = cycle[:buffer] == @editor_state.buffer
87
+ same_cursor = cycle[:cursor] == @editor_state.cursor
88
+ return cycle if same_editor && same_buffer && same_cursor
89
+
90
+ reset_editor_word_completion
91
+ nil
92
+ end
93
+
94
+ def cycle_editor_word_completion(cycle)
95
+ cycle[:index] = (cycle[:index] + 1) % cycle[:candidates].length
96
+ apply_editor_word_completion(cycle)
97
+ true
98
+ end
99
+
100
+ def apply_editor_word_completion(cycle)
101
+ candidate = cycle[:candidates][cycle[:index]]
102
+ @editor_state.replace_range(cycle[:range_start], cycle[:range_end], candidate)
103
+ @editor_state.cursor = cycle[:range_start] + candidate.length
104
+ cycle[:range_end] = @editor_state.cursor
105
+ cycle[:buffer] = @editor_state.buffer
106
+ cycle[:cursor] = @editor_state.cursor
107
+ cycle[:status] = "Completion #{cycle[:index] + 1}/#{cycle[:candidates].length}: #{candidate}"
108
+ @editor_state.status = cycle[:status]
109
+ end
110
+
111
+ def reset_editor_word_completion
112
+ cycle = @editor_word_completion
113
+ if cycle && cycle[:editor_state].status == cycle[:status]
114
+ cycle[:editor_state].status = cycle[:previous_status]
115
+ end
116
+ @editor_word_completion = nil
117
+ end
118
+
119
+ def editor_word_completion_tab_key?(key)
120
+ !editor_tab_sequence_for(key).nil?
121
+ end
122
+ end
123
+ end
124
+ end
@@ -114,8 +114,12 @@ module Kward
114
114
  end
115
115
  end
116
116
 
117
- def project_file_paths
118
- @file_mention_paths ||= discover_project_file_paths
117
+ def project_file_paths(include_ignored: false)
118
+ if include_ignored
119
+ @project_browser_file_paths ||= discover_project_file_paths(include_ignored: true)
120
+ else
121
+ @file_mention_paths ||= discover_project_file_paths
122
+ end
119
123
  end
120
124
 
121
125
  def project_file_path_entries
@@ -126,14 +130,24 @@ module Kward
126
130
  @file_mention_path_entries = paths.map { |path| { path: path, downcase: path.downcase } }
127
131
  end
128
132
 
129
- def discover_project_file_paths
130
- paths = git_project_file_paths
131
- paths = scanned_project_file_paths if paths.empty?
133
+ def discover_project_file_paths(include_ignored: false)
134
+ paths = if include_ignored
135
+ git_project_file_paths(include_ignored: true)
136
+ else
137
+ git_project_file_paths
138
+ end
139
+ paths = scanned_project_file_paths if paths.empty? && !git_project_repository?
132
140
  paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
133
141
  end
134
142
 
135
- def git_project_file_paths
136
- ProjectFiles.git_paths(prompt_workspace_root)
143
+ def git_project_file_paths(include_ignored: false)
144
+ return ProjectFiles.git_paths(prompt_workspace_root) unless include_ignored
145
+
146
+ ProjectFiles.git_paths(prompt_workspace_root, include_ignored: true)
147
+ end
148
+
149
+ def git_project_repository?
150
+ ProjectFiles.git_repository?(prompt_workspace_root)
137
151
  end
138
152
 
139
153
  def scanned_project_file_paths
@@ -20,6 +20,14 @@ module Kward
20
20
  end
21
21
 
22
22
  def handle_editor_input_key(key)
23
+ if @editor_agent_suspended
24
+ return CANCEL_INPUT if @busy && key == TerminalKeys::CTRL_C
25
+
26
+ return true
27
+ end
28
+
29
+ reset_editor_word_completion unless editor_word_completion_tab_key?(key)
30
+
23
31
  tab_result = handle_tab_key_binding(key)
24
32
  return tab_result unless tab_result == false
25
33
 
@@ -48,10 +48,12 @@ module Kward
48
48
  end
49
49
 
50
50
  def open_project_browser_locked
51
- paths = project_file_paths
52
51
  saved_state = saved_project_browser_state
52
+ show_ignored = saved_state["show_ignored"] == true
53
+ paths = project_file_paths(include_ignored: show_ignored)
53
54
  @project_browser_state = {
54
55
  paths: paths,
56
+ show_ignored: show_ignored,
55
57
  expanded: restored_project_browser_expanded_paths(paths, saved_state),
56
58
  selection_index: 0,
57
59
  search_active: false,
@@ -142,6 +144,8 @@ module Kward
142
144
  collapse_selected_project_browser_row
143
145
  elsif text == "l" && !project_browser_search_active?
144
146
  expand_selected_project_browser_row
147
+ elsif text == "i" && !project_browser_search_active?
148
+ toggle_project_browser_ignored_files
145
149
  elsif project_browser_search_active?
146
150
  project_browser_append_search(text)
147
151
  else
@@ -173,6 +177,8 @@ module Kward
173
177
  project_browser_search_active? ? project_browser_append_search(key) : collapse_selected_project_browser_row
174
178
  when "l"
175
179
  project_browser_search_active? ? project_browser_append_search(key) : expand_selected_project_browser_row
180
+ when "i"
181
+ project_browser_search_active? ? project_browser_append_search(key) : toggle_project_browser_ignored_files
176
182
  else
177
183
  project_browser_append_search(key) if project_browser_search_active? && printable_key?(key)
178
184
  end
@@ -190,6 +196,21 @@ module Kward
190
196
  project_browser_search_active? ? deactivate_project_browser_search : activate_project_browser_search
191
197
  end
192
198
 
199
+ def toggle_project_browser_ignored_files
200
+ selected_path = selected_project_browser_row&.[](:path)
201
+ show_ignored = !project_browser_ignored_files_visible?
202
+ @project_browser_state[:paths] = project_file_paths(include_ignored: show_ignored)
203
+ @project_browser_state[:show_ignored] = show_ignored
204
+ @project_browser_tree_paths = nil
205
+ @project_browser_tree = nil
206
+ restore_project_browser_selection(selected_path)
207
+ persist_project_browser_state
208
+ end
209
+
210
+ def project_browser_ignored_files_visible?
211
+ @project_browser_state[:show_ignored] == true
212
+ end
213
+
193
214
  def activate_project_browser_search
194
215
  @project_browser_state[:search_active] = true
195
216
  @project_browser_state[:query] = ""
@@ -468,10 +489,12 @@ module Kward
468
489
 
469
490
  @project_browser_restore_after_editor = false
470
491
  unless @project_browser_state
471
- paths = project_file_paths
472
492
  saved_state = saved_project_browser_state
493
+ show_ignored = saved_state["show_ignored"] == true
494
+ paths = project_file_paths(include_ignored: show_ignored)
473
495
  @project_browser_state = {
474
496
  paths: paths,
497
+ show_ignored: show_ignored,
475
498
  expanded: restored_project_browser_expanded_paths(paths, saved_state),
476
499
  selection_index: 0,
477
500
  search_active: false,
@@ -507,7 +530,8 @@ module Kward
507
530
 
508
531
  def project_browser_title
509
532
  query = @project_browser_state[:query].to_s
510
- project_browser_search_active? ? "Project files — Search: #{query}" : "Project files"
533
+ title = project_browser_search_active? ? "Project files — Search: #{query}" : "Project files"
534
+ project_browser_ignored_files_visible? ? "#{title} (including ignored)" : title
511
535
  end
512
536
 
513
537
  def project_browser_empty_message
@@ -518,7 +542,7 @@ module Kward
518
542
  if project_browser_search_active?
519
543
  "Type search • Esc tree • Enter open • @ mention"
520
544
  else
521
- "Enter open/toggle • ←/→ collapse/expand • Tab or / search • @ mention • Esc close"
545
+ "Enter open/toggle • i show/hide ignored • ←/→ collapse/expand • Tab or / search • @ mention • Esc close"
522
546
  end
523
547
  end
524
548
 
@@ -631,7 +655,8 @@ module Kward
631
655
  row = selected_project_browser_row
632
656
  workspaces[project_browser_workspace_root] = {
633
657
  "expanded" => @project_browser_state[:expanded].to_a.sort,
634
- "selected_path" => row && row[:path]
658
+ "selected_path" => row && row[:path],
659
+ "show_ignored" => project_browser_ignored_files_visible?
635
660
  }
636
661
  data["version"] = PROJECT_BROWSER_STATE_VERSION
637
662
  data["workspaces"] = workspaces
@@ -118,8 +118,12 @@ module Kward
118
118
  result.is_a?(Hash) && result[:reasoning_action]
119
119
  end
120
120
 
121
+ def editor_prompt_action?(result)
122
+ result.is_a?(Hash) && result[:editor_prompt]
123
+ end
124
+
121
125
  def prompt_action_result?(result)
122
- tab_action_result?(result) || reasoning_action_result?(result)
126
+ tab_action_result?(result) || reasoning_action_result?(result) || editor_prompt_action?(result)
123
127
  end
124
128
 
125
129
  end