kward 0.71.0 → 0.72.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -0,0 +1,524 @@
1
+ require "json"
2
+ require "set"
3
+ require_relative "../config_files"
4
+ require_relative "../private_file"
5
+
6
+ # Namespace for the Kward CLI agent runtime.
7
+ module Kward
8
+ # Nested project file browser overlay behavior.
9
+ class PromptInterface
10
+ # Modal tree browser for project files.
11
+ module ProjectBrowser
12
+ PROJECT_BROWSER_ROOT = "".freeze
13
+ PROJECT_BROWSER_RESULT_LIMIT = 200
14
+ PROJECT_BROWSER_STATE_VERSION = 1
15
+
16
+ def open_project_browser
17
+ @mutex.synchronize do
18
+ open_project_browser_locked
19
+ render_prompt_locked if @started && @asking
20
+ end
21
+ true
22
+ end
23
+
24
+ def open_project_browser_locked
25
+ paths = project_file_paths
26
+ saved_state = saved_project_browser_state
27
+ @project_browser_state = {
28
+ paths: paths,
29
+ expanded: restored_project_browser_expanded_paths(paths, saved_state),
30
+ selection_index: 0,
31
+ search_active: false,
32
+ query: ""
33
+ }
34
+ restore_project_browser_selection(saved_state["selected_path"])
35
+ self.composer_input = ""
36
+ self.composer_cursor = 0
37
+ @pending_keys.clear
38
+ @asking = true
39
+ end
40
+
41
+ private
42
+
43
+ def project_browser_visible?
44
+ !@project_browser_state.nil? && !editor_active?
45
+ end
46
+
47
+ def dismiss_project_browser
48
+ return false unless project_browser_visible?
49
+
50
+ persist_project_browser_state unless project_browser_search_active?
51
+ @project_browser_state = nil
52
+ true
53
+ end
54
+
55
+ def handle_project_browser_key(key)
56
+ return true if handle_bundled_key(key) { |token| handle_project_browser_key(token) }
57
+
58
+ csi_result = handle_project_browser_csi_u_key(key)
59
+ return csi_result unless csi_result == false
60
+
61
+ case key_name_for(key)
62
+ when :return, :enter
63
+ open_or_toggle_selected_project_browser_row
64
+ when :backspace
65
+ project_browser_delete_search_character
66
+ when :ctrl_l
67
+ redraw_screen_locked
68
+ when :left
69
+ collapse_selected_project_browser_row
70
+ when :right
71
+ expand_selected_project_browser_row
72
+ when :up
73
+ select_previous_project_browser_row
74
+ when :down
75
+ select_next_project_browser_row
76
+ else
77
+ handle_project_browser_raw_key(key)
78
+ end
79
+ true
80
+ end
81
+
82
+ def handle_project_browser_csi_u_key(key)
83
+ sequence = parse_csi_u_key(key)
84
+ return false unless sequence
85
+
86
+ queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
87
+ case sequence[:code]
88
+ when 13
89
+ open_or_toggle_selected_project_browser_row
90
+ when 27
91
+ project_browser_escape
92
+ when 8, 127
93
+ project_browser_delete_search_character
94
+ when 9
95
+ return false if ctrl_modifier?(sequence[:modifier]) || alt_modifier?(sequence[:modifier]) || super_modifier?(sequence[:modifier])
96
+
97
+ toggle_project_browser_search
98
+ else
99
+ text = csi_u_printable_text(sequence)
100
+ return false unless text
101
+
102
+ if text == "@"
103
+ insert_selected_project_browser_mention
104
+ elsif text == "/" && !project_browser_search_active?
105
+ activate_project_browser_search
106
+ elsif project_browser_search_active?
107
+ project_browser_append_search(text)
108
+ else
109
+ return false
110
+ end
111
+ end
112
+ true
113
+ end
114
+
115
+ def handle_project_browser_raw_key(key)
116
+ case key
117
+ when "\n", "\r"
118
+ open_or_toggle_selected_project_browser_row
119
+ when "\b", "\x7F"
120
+ project_browser_delete_search_character
121
+ when "\e"
122
+ project_browser_escape
123
+ when "@"
124
+ insert_selected_project_browser_mention
125
+ when "\t"
126
+ toggle_project_browser_search
127
+ when "/"
128
+ activate_project_browser_search
129
+ else
130
+ project_browser_append_search(key) if project_browser_search_active? && printable_key?(key)
131
+ end
132
+ end
133
+
134
+ def project_browser_escape
135
+ if project_browser_search_active?
136
+ deactivate_project_browser_search
137
+ else
138
+ dismiss_project_browser
139
+ end
140
+ end
141
+
142
+ def toggle_project_browser_search
143
+ project_browser_search_active? ? deactivate_project_browser_search : activate_project_browser_search
144
+ end
145
+
146
+ def activate_project_browser_search
147
+ @project_browser_state[:search_active] = true
148
+ @project_browser_state[:query] = ""
149
+ @project_browser_state[:selection_index] = 0
150
+ sync_project_browser_query_input
151
+ end
152
+
153
+ def deactivate_project_browser_search
154
+ @project_browser_state[:search_active] = false
155
+ @project_browser_state[:query] = ""
156
+ sync_project_browser_query_input
157
+ clamp_project_browser_selection
158
+ persist_project_browser_state
159
+ end
160
+
161
+ def project_browser_append_search(key)
162
+ @project_browser_state[:query] += key
163
+ @project_browser_state[:selection_index] = 0
164
+ sync_project_browser_query_input
165
+ end
166
+
167
+ def project_browser_delete_search_character
168
+ return unless project_browser_search_active?
169
+ return if @project_browser_state[:query].empty?
170
+
171
+ @project_browser_state[:query] = @project_browser_state[:query][0...-1]
172
+ sync_project_browser_query_input
173
+ clamp_project_browser_selection
174
+ end
175
+
176
+ def sync_project_browser_query_input
177
+ self.composer_input = project_browser_search_active? ? @project_browser_state[:query].to_s : ""
178
+ self.composer_cursor = composer_input.length
179
+ end
180
+
181
+ def project_browser_search_active?
182
+ @project_browser_state && @project_browser_state[:search_active]
183
+ end
184
+
185
+ def select_previous_project_browser_row
186
+ rows = project_browser_visible_rows
187
+ return if rows.empty?
188
+
189
+ @project_browser_state[:selection_index] = previous_list_selection_index(@project_browser_state[:selection_index], rows.length)
190
+ persist_project_browser_state unless project_browser_search_active?
191
+ end
192
+
193
+ def select_next_project_browser_row
194
+ rows = project_browser_visible_rows
195
+ return if rows.empty?
196
+
197
+ @project_browser_state[:selection_index] = next_list_selection_index(@project_browser_state[:selection_index], rows.length)
198
+ persist_project_browser_state unless project_browser_search_active?
199
+ end
200
+
201
+ def open_or_toggle_selected_project_browser_row
202
+ row = selected_project_browser_row
203
+ return false unless row
204
+
205
+ if row[:directory]
206
+ toggle_project_browser_directory(row[:path])
207
+ true
208
+ else
209
+ persist_project_browser_state unless project_browser_search_active?
210
+ @project_browser_restore_after_editor = true if open_editor(row[:path])
211
+ true
212
+ end
213
+ end
214
+
215
+ def expand_selected_project_browser_row
216
+ row = selected_project_browser_row
217
+ return false unless row&.fetch(:directory, false)
218
+
219
+ @project_browser_state[:expanded].add(row[:path])
220
+ persist_project_browser_state
221
+ true
222
+ end
223
+
224
+ def collapse_selected_project_browser_row
225
+ row = selected_project_browser_row
226
+ return false unless row
227
+
228
+ if row[:directory] && @project_browser_state[:expanded].include?(row[:path])
229
+ @project_browser_state[:expanded].delete(row[:path]) unless row[:path] == PROJECT_BROWSER_ROOT
230
+ clamp_project_browser_selection
231
+ persist_project_browser_state
232
+ true
233
+ else
234
+ select_project_browser_parent(row[:path])
235
+ end
236
+ end
237
+
238
+ def toggle_project_browser_directory(path)
239
+ expanded = @project_browser_state[:expanded]
240
+ if expanded.include?(path)
241
+ expanded.delete(path) unless path == PROJECT_BROWSER_ROOT
242
+ else
243
+ expanded.add(path)
244
+ end
245
+ clamp_project_browser_selection
246
+ persist_project_browser_state
247
+ end
248
+
249
+ def select_project_browser_parent(path)
250
+ parent = File.dirname(path.to_s)
251
+ parent = PROJECT_BROWSER_ROOT if parent == "."
252
+ rows = project_browser_visible_rows
253
+ index = rows.index { |row| row[:directory] && row[:path] == parent }
254
+ return unless index
255
+
256
+ @project_browser_state[:selection_index] = index
257
+ persist_project_browser_state unless project_browser_search_active?
258
+ end
259
+
260
+ def insert_selected_project_browser_mention
261
+ row = selected_project_browser_row
262
+ return false unless row && !row[:directory]
263
+
264
+ persist_project_browser_state unless project_browser_search_active?
265
+ self.composer_input = "@#{row[:path]}"
266
+ self.composer_cursor = composer_input.length
267
+ dismiss_project_browser
268
+ true
269
+ end
270
+
271
+ def restore_project_browser_after_editor_close
272
+ return unless @project_browser_restore_after_editor
273
+
274
+ @project_browser_restore_after_editor = false
275
+ unless @project_browser_state
276
+ paths = project_file_paths
277
+ saved_state = saved_project_browser_state
278
+ @project_browser_state = {
279
+ paths: paths,
280
+ expanded: restored_project_browser_expanded_paths(paths, saved_state),
281
+ selection_index: 0,
282
+ search_active: false,
283
+ query: ""
284
+ }
285
+ restore_project_browser_selection(saved_state["selected_path"])
286
+ end
287
+ sync_project_browser_query_input
288
+ clamp_project_browser_selection
289
+ end
290
+
291
+ def project_browser_rows(width, height: screen_height)
292
+ return [] unless project_browser_visible?
293
+
294
+ rows = project_browser_visible_rows
295
+ lines = []
296
+ title = project_browser_title
297
+ if rows.empty?
298
+ lines << overlay_text_line(project_browser_empty_message, :muted)
299
+ else
300
+ visible = visible_project_browser_rows(rows, height: height)
301
+ visible[:rows].each_with_index do |row, offset|
302
+ index = visible[:start] + offset
303
+ lines << overlay_choice_line(project_browser_row_text(row), selected: index == @project_browser_state[:selection_index])
304
+ end
305
+ end
306
+ lines << overlay_blank_line
307
+ lines << overlay_text_line(project_browser_help_text, :muted)
308
+ overlay_card_rows(title, lines, width)
309
+ end
310
+
311
+ def project_browser_title
312
+ query = @project_browser_state[:query].to_s
313
+ project_browser_search_active? ? "Project files — Search: #{query}" : "Project files"
314
+ end
315
+
316
+ def project_browser_empty_message
317
+ project_browser_search_active? ? "No matching files" : "No project files"
318
+ end
319
+
320
+ def project_browser_help_text
321
+ if project_browser_search_active?
322
+ "Type search • Esc tree • Enter open • @ mention"
323
+ else
324
+ "Enter open/toggle • ←/→ collapse/expand • Tab or / search • @ mention • Esc close"
325
+ end
326
+ end
327
+
328
+ def project_browser_visible_rows
329
+ return [] unless @project_browser_state
330
+ return project_browser_search_rows if project_browser_search_active?
331
+
332
+ tree = project_browser_tree
333
+ directory_children = tree[:directories].fetch(PROJECT_BROWSER_ROOT, [])
334
+ file_children = tree[:files].fetch(PROJECT_BROWSER_ROOT, [])
335
+ rows = []
336
+ append_project_browser_rows(rows, directory_children, file_children, tree, 0)
337
+ rows
338
+ end
339
+
340
+ def append_project_browser_rows(rows, directories, files, tree, depth)
341
+ directories.each do |directory|
342
+ expanded = @project_browser_state[:expanded].include?(directory)
343
+ rows << { path: directory, name: File.basename(directory), depth: depth, directory: true, expanded: expanded }
344
+ next unless expanded
345
+
346
+ append_project_browser_rows(
347
+ rows,
348
+ tree[:directories].fetch(directory, []),
349
+ tree[:files].fetch(directory, []),
350
+ tree,
351
+ depth + 1
352
+ )
353
+ end
354
+ files.each do |file|
355
+ rows << { path: file, name: File.basename(file), depth: depth, directory: false }
356
+ end
357
+ end
358
+
359
+ def project_browser_search_rows
360
+ query = @project_browser_state[:query].downcase
361
+ matches = []
362
+ project_file_path_entries.each do |entry|
363
+ next unless file_mention_match?(entry[:downcase], query)
364
+
365
+ matches << { path: entry[:path], name: entry[:path], depth: 0, directory: false }
366
+ break if matches.length >= PROJECT_BROWSER_RESULT_LIMIT
367
+ end
368
+ matches
369
+ end
370
+
371
+ def project_browser_tree
372
+ paths = @project_browser_state[:paths]
373
+ return @project_browser_tree if @project_browser_tree_paths.equal?(paths) && @project_browser_tree
374
+
375
+ directories = Hash.new { |hash, key| hash[key] = Set.new }
376
+ files = Hash.new { |hash, key| hash[key] = [] }
377
+ paths.each do |path|
378
+ parts = path.split("/")
379
+ parent = PROJECT_BROWSER_ROOT
380
+ parts[0...-1].each do |part|
381
+ directory = parent.empty? ? part : "#{parent}/#{part}"
382
+ directories[parent].add(directory)
383
+ parent = directory
384
+ end
385
+ files[parent] << path
386
+ end
387
+
388
+ @project_browser_tree_paths = paths
389
+ @project_browser_tree = {
390
+ directories: directories.transform_values { |values| values.to_a.sort },
391
+ files: files.transform_values(&:sort)
392
+ }
393
+ end
394
+
395
+ def project_browser_row_text(row)
396
+ indent = " " * row[:depth]
397
+ marker = if row[:directory]
398
+ row[:expanded] ? "▾ " : "▸ "
399
+ else
400
+ " "
401
+ end
402
+ suffix = row[:directory] ? "/" : ""
403
+ "#{indent}#{marker}#{row[:name]}#{suffix}"
404
+ end
405
+
406
+ def saved_project_browser_state
407
+ workspaces = read_project_browser_state_file["workspaces"]
408
+ state = workspaces[project_browser_workspace_root] if workspaces.is_a?(Hash)
409
+ state.is_a?(Hash) ? state : {}
410
+ end
411
+
412
+ def persist_project_browser_state
413
+ return unless @project_browser_state
414
+
415
+ data = read_project_browser_state_file
416
+ workspaces = data["workspaces"].is_a?(Hash) ? data["workspaces"] : {}
417
+ row = selected_project_browser_row
418
+ workspaces[project_browser_workspace_root] = {
419
+ "expanded" => @project_browser_state[:expanded].to_a.sort,
420
+ "selected_path" => row && row[:path]
421
+ }
422
+ data["version"] = PROJECT_BROWSER_STATE_VERSION
423
+ data["workspaces"] = workspaces
424
+ PrivateFile.write_json(ConfigFiles.project_browser_state_path, data)
425
+ rescue StandardError
426
+ nil
427
+ end
428
+
429
+ def read_project_browser_state_file
430
+ path = ConfigFiles.project_browser_state_path
431
+ return { "version" => PROJECT_BROWSER_STATE_VERSION, "workspaces" => {} } unless File.exist?(path)
432
+
433
+ data = JSON.parse(File.read(path))
434
+ data.is_a?(Hash) ? data : { "version" => PROJECT_BROWSER_STATE_VERSION, "workspaces" => {} }
435
+ rescue JSON::ParserError
436
+ { "version" => PROJECT_BROWSER_STATE_VERSION, "workspaces" => {} }
437
+ end
438
+
439
+ def project_browser_workspace_root
440
+ ConfigFiles.canonical_workspace_root(Dir.pwd)
441
+ end
442
+
443
+ def restored_project_browser_expanded_paths(paths, saved_state)
444
+ directories = project_browser_directory_paths(paths)
445
+ saved_expanded = saved_state["expanded"]
446
+ expanded = if saved_expanded.is_a?(Array)
447
+ Set.new(saved_expanded.select { |path| directories.include?(path.to_s) })
448
+ else
449
+ default_project_browser_expanded_paths(paths)
450
+ end
451
+ expanded.add(PROJECT_BROWSER_ROOT)
452
+ expanded
453
+ end
454
+
455
+ def project_browser_directory_paths(paths)
456
+ directories = Set.new([PROJECT_BROWSER_ROOT])
457
+ paths.each do |path|
458
+ parent = PROJECT_BROWSER_ROOT
459
+ path.split("/")[0...-1].each do |part|
460
+ parent = parent.empty? ? part : "#{parent}/#{part}"
461
+ directories.add(parent)
462
+ end
463
+ end
464
+ directories
465
+ end
466
+
467
+ def restore_project_browser_selection(path)
468
+ rows = project_browser_visible_rows
469
+ return @project_browser_state[:selection_index] = 0 if rows.empty?
470
+
471
+ index = project_browser_selection_fallback_paths(path).filter_map do |candidate|
472
+ rows.index { |row| row[:path] == candidate }
473
+ end.first
474
+ @project_browser_state[:selection_index] = index || 0
475
+ end
476
+
477
+ def project_browser_selection_fallback_paths(path)
478
+ current = path.to_s
479
+ candidates = []
480
+ until current.empty? || current == "."
481
+ candidates << current
482
+ current = File.dirname(current)
483
+ end
484
+ candidates
485
+ end
486
+
487
+ def selected_project_browser_row
488
+ rows = project_browser_visible_rows
489
+ return nil if rows.empty?
490
+
491
+ rows[[@project_browser_state[:selection_index], rows.length - 1].min]
492
+ end
493
+
494
+ def clamp_project_browser_selection
495
+ rows = project_browser_visible_rows
496
+ @project_browser_state[:selection_index] = 0 if rows.empty?
497
+ @project_browser_state[:selection_index] = [[@project_browser_state[:selection_index], 0].max, rows.length - 1].min unless rows.empty?
498
+ end
499
+
500
+ def visible_project_browser_rows(rows, height: screen_height)
501
+ max_rows = max_project_browser_rows(height)
502
+ start = centered_list_window_start(@project_browser_state[:selection_index], rows.length, max_rows)
503
+ { start: start, rows: rows[start, max_rows] || [] }
504
+ end
505
+
506
+ def max_project_browser_rows(height)
507
+ [[height - 8, 4].max, 20].min
508
+ end
509
+
510
+ def default_project_browser_expanded_paths(paths)
511
+ expanded = Set.new([PROJECT_BROWSER_ROOT])
512
+ paths.each do |path|
513
+ parts = path.split("/")
514
+ parent = PROJECT_BROWSER_ROOT
515
+ parts[0...-1].first(2).each do |part|
516
+ parent = parent.empty? ? part : "#{parent}/#{part}"
517
+ expanded.add(parent)
518
+ end
519
+ end
520
+ expanded
521
+ end
522
+ end
523
+ end
524
+ end