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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/agent-tools.md +1 -0
- data/doc/api.md +4 -0
- data/doc/composer.md +2 -2
- data/doc/configuration.md +82 -20
- data/doc/editor.md +47 -22
- data/doc/files.md +12 -7
- data/doc/getting-started.md +3 -0
- data/doc/pan.md +19 -15
- data/doc/permissions.md +1 -0
- data/doc/platform-support.md +48 -0
- data/doc/releasing.md +12 -4
- data/doc/rpc.md +2 -2
- data/doc/sandboxing.md +5 -1
- data/doc/security.md +4 -3
- data/doc/shell.md +101 -60
- data/doc/tabs.md +3 -0
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +7 -6
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +7 -3
- data/lib/kward/ansi.rb +110 -10
- data/lib/kward/auth/anthropic_oauth.rb +7 -7
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +144 -21
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +27 -0
- data/lib/kward/cli/rendering.rb +15 -9
- data/lib/kward/cli/runtime_helpers.rb +260 -55
- data/lib/kward/cli/slash_commands.rb +21 -12
- data/lib/kward/cli/tabs.rb +91 -13
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/cli.rb +55 -7
- data/lib/kward/cli_transcript_formatter.rb +11 -4
- data/lib/kward/compaction/token_estimator.rb +12 -6
- data/lib/kward/config_files.rb +94 -63
- data/lib/kward/detached_run.rb +44 -0
- data/lib/kward/editor_prompt.rb +46 -0
- data/lib/kward/editor_prompt_session.rb +28 -0
- data/lib/kward/interactive_pty_runner.rb +102 -28
- data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
- data/lib/kward/kwshrc.rb +233 -0
- data/lib/kward/markdown_code_block.rb +136 -0
- data/lib/kward/model/client.rb +35 -39
- data/lib/kward/model/model_info.rb +3 -2
- data/lib/kward/model/payloads.rb +0 -2
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +23 -3
- data/lib/kward/persistent_shell_session.rb +843 -0
- data/lib/kward/project_files.rb +19 -6
- data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
- data/lib/kward/prompt_interface/editor/controller.rb +99 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
- data/lib/kward/prompt_interface/editor/renderer.rb +109 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/state.rb +21 -10
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
- data/lib/kward/prompt_interface/file_overlay.rb +92 -22
- data/lib/kward/prompt_interface/key_handler.rb +75 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/project_browser.rb +443 -17
- data/lib/kward/prompt_interface/runtime_state.rb +55 -2
- data/lib/kward/prompt_interface/screen.rb +2 -2
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +169 -18
- data/lib/kward/prompts/commands.rb +2 -1
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/pty_output_sink.rb +47 -0
- data/lib/kward/rpc/auth_manager.rb +1 -1
- data/lib/kward/rpc/server.rb +2 -1
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/scratchpad_languages.rb +74 -0
- data/lib/kward/scratchpad_runner.rb +155 -29
- data/lib/kward/shell_prompt.rb +52 -0
- data/lib/kward/shell_prompt_session.rb +58 -0
- data/lib/kward/terminal_keys.rb +13 -0
- data/lib/kward/terminal_text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/prepare_shell_command.rb +28 -0
- data/lib/kward/tools/registry.rb +75 -8
- data/lib/kward/tools/replace_editor_buffer.rb +30 -0
- data/lib/kward/tools/run_shell_command.rb +24 -6
- data/lib/kward/tools/tool_call.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/fulldoc/html/css/kward.css +0 -125
- data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
- data/templates/default/fulldoc/html/setup.rb +1 -1
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +6 -18
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +44 -8
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "fileutils"
|
|
1
2
|
require "json"
|
|
2
3
|
require "set"
|
|
3
4
|
require_relative "../config_files"
|
|
@@ -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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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? ? "
|
|
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
|
|