kward 0.80.1 → 0.81.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/doc/composer.md +5 -8
  6. data/doc/configuration.md +13 -3
  7. data/doc/permissions.md +1 -1
  8. data/doc/rpc.md +2 -1
  9. data/doc/sandboxing.md +4 -4
  10. data/doc/security.md +6 -9
  11. data/doc/shell.md +161 -196
  12. data/doc/skills.md +17 -5
  13. data/doc/tabs.md +1 -1
  14. data/doc/usage.md +6 -5
  15. data/lib/kward/ansi.rb +9 -1
  16. data/lib/kward/cli/commands.rb +7 -0
  17. data/lib/kward/cli/interactive_turn.rb +1 -1
  18. data/lib/kward/cli/plugins.rb +1 -1
  19. data/lib/kward/cli/project_skills.rb +99 -0
  20. data/lib/kward/cli/project_skills_commands.rb +87 -0
  21. data/lib/kward/cli/prompt_interface.rb +18 -4
  22. data/lib/kward/cli/rendering.rb +28 -2
  23. data/lib/kward/cli/runtime_helpers.rb +156 -19
  24. data/lib/kward/cli/sessions.rb +6 -4
  25. data/lib/kward/cli/settings.rb +1 -1
  26. data/lib/kward/cli/slash_commands.rb +6 -0
  27. data/lib/kward/cli/tabs.rb +3 -1
  28. data/lib/kward/cli.rb +19 -1
  29. data/lib/kward/config_files.rb +9 -4
  30. data/lib/kward/conversation.rb +8 -5
  31. data/lib/kward/ekwsh.rb +37 -16
  32. data/lib/kward/interactive_pty_runner.rb +88 -22
  33. data/lib/kward/prompt_interface/composer_controller.rb +13 -1
  34. data/lib/kward/prompt_interface/editor/controller.rb +4 -0
  35. data/lib/kward/prompt_interface/key_handler.rb +78 -43
  36. data/lib/kward/prompt_interface/overlay_renderer.rb +12 -0
  37. data/lib/kward/prompt_interface/screen.rb +5 -0
  38. data/lib/kward/prompt_interface.rb +93 -21
  39. data/lib/kward/prompts/commands.rb +2 -0
  40. data/lib/kward/prompts.rb +6 -6
  41. data/lib/kward/rpc/server.rb +1 -0
  42. data/lib/kward/session_store.rb +3 -2
  43. data/lib/kward/skills/registry.rb +52 -2
  44. data/lib/kward/skills/trust_coordinator.rb +45 -0
  45. data/lib/kward/skills/trust_store.rb +107 -0
  46. data/lib/kward/version.rb +1 -1
  47. metadata +5 -1
data/lib/kward/cli.rb CHANGED
@@ -59,6 +59,8 @@ require_relative "cli/sysprompt"
59
59
  require_relative "cli/stats"
60
60
  require_relative "cli/openrouter_commands"
61
61
  require_relative "cli/runtime_helpers"
62
+ require_relative "cli/project_skills"
63
+ require_relative "cli/project_skills_commands"
62
64
  require_relative "cli/slash_commands"
63
65
  require_relative "cli/memory_commands"
64
66
  require_relative "cli/hook_commands"
@@ -94,6 +96,8 @@ module Kward
94
96
  include CLI::Stats
95
97
  include CLI::OpenRouterCommands
96
98
  include CLI::RuntimeHelpers
99
+ include CLI::ProjectSkills
100
+ include CLI::ProjectSkillsCommands
97
101
  include CLI::SlashCommands
98
102
  include CLI::MemoryCommands
99
103
  include CLI::HookCommands
@@ -232,6 +236,16 @@ module Kward
232
236
  return
233
237
  end
234
238
 
239
+ if @argv.first == "skills"
240
+ if help_option_arguments?(@argv[1..] || [])
241
+ print_command_help("skills")
242
+ return
243
+ end
244
+
245
+ handle_project_skills_cli_command(@argv[1..] || [])
246
+ return
247
+ end
248
+
235
249
  if @argv.first == "edit"
236
250
  if help_option_arguments?(@argv[1..] || [])
237
251
  print_command_help("edit")
@@ -430,7 +444,8 @@ module Kward
430
444
  end
431
445
 
432
446
  def interactive_loop(agent: nil)
433
- setup_interactive_prompt
447
+ setup_interactive_prompt(defer_warnings: true)
448
+ prepare_interactive_project_skills
434
449
  session_store = interactive_session_store(agent)
435
450
  @resumed_last_session = false
436
451
  if session_store && @prompt.respond_to?(:update_tabs)
@@ -450,8 +465,10 @@ module Kward
450
465
 
451
466
  print_visual_banner unless @resumed_last_session || @restored_tabs
452
467
  render_resumed_last_session_transcript(agent.conversation) if @resumed_last_session
468
+ enable_interactive_warnings if respond_to?(:enable_interactive_warnings, true)
453
469
 
454
470
  @pending_inputs = []
471
+ install_bang_completion_provider(agent)
455
472
 
456
473
  loop do
457
474
  if @pending_inputs.empty? && active_tab&.shell
@@ -543,6 +560,7 @@ module Kward
543
560
  runtime_output("Goodbye.")
544
561
  active_tab&.agent&.conversation || agent&.conversation
545
562
  ensure
563
+ clear_bang_completion_provider
546
564
  begin
547
565
  stop_tabs if respond_to?(:stop_tabs, true)
548
566
  @prompt.close if prompt_interface?
@@ -917,8 +917,12 @@ module Kward
917
917
  # Lists configured skills discovered under the config directory.
918
918
  #
919
919
  # @return [Array<Skill>] skill metadata available to the model
920
- def skills(workspace_root: Dir.pwd, warning_sink: nil)
921
- skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).skills
920
+ def skills(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
921
+ skills_registry(workspace_root: workspace_root, warning_sink: warning_sink, project_skill_paths: project_skill_paths).skills
922
+ end
923
+
924
+ def project_skill_candidates(workspace_root: Dir.pwd, warning_sink: nil)
925
+ skills_registry(workspace_root: workspace_root, warning_sink: warning_sink).project_skill_candidates
922
926
  end
923
927
 
924
928
  # @return [String] trusted user plugin directory
@@ -961,7 +965,7 @@ module Kward
961
965
  skills_registry(workspace_root: workspace_root).read_skill_file(name, relative_path)
962
966
  end
963
967
 
964
- def skills_registry(workspace_root: Dir.pwd, warning_sink: nil)
968
+ def skills_registry(workspace_root: Dir.pwd, warning_sink: nil, project_skill_paths: nil)
965
969
  Skills::Registry.new(
966
970
  config_dir: config_dir,
967
971
  workspace_root: workspace_root,
@@ -970,7 +974,8 @@ module Kward
970
974
  max_file_bytes: MAX_SKILL_FILE_BYTES,
971
975
  markdown_parser: ->(path) { Frontmatter.markdown_parts(path, lenient: true) },
972
976
  inside_directory: method(:inside_directory?),
973
- warning_sink: warning_sink || @warning_sink
977
+ warning_sink: warning_sink || @warning_sink,
978
+ project_skill_paths: project_skill_paths
974
979
  )
975
980
  end
976
981
 
@@ -63,13 +63,16 @@ module Kward
63
63
  attr_reader :tool_output_artifacts
64
64
  # @return [ContextBudgetMeter] runtime context savings for this conversation
65
65
  attr_reader :context_budget_meter
66
+ # @return [Array<String>, nil] explicitly permitted project skill paths
67
+ attr_reader :project_skill_paths
66
68
 
67
- def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil, execution_profile_context: nil)
69
+ def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil, execution_profile_context: nil, project_skill_paths: nil)
68
70
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
69
71
  @provider = provider
70
72
  @model = model
71
73
  @reasoning_effort = reasoning_effort
72
74
  @plugin_registry = plugin_registry
75
+ @project_skill_paths = project_skill_paths
73
76
  @execution_profile_context = execution_profile_context.to_s.empty? ? nil : execution_profile_context.to_s.freeze
74
77
  @messages = []
75
78
  restored_system_message, transcript_messages = split_system_message(messages)
@@ -78,13 +81,13 @@ module Kward
78
81
  system_message = restored_system_message
79
82
  else
80
83
  @last_plugin_prompt_context = plugin_prompt_context
81
- system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
84
+ system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths)
82
85
  end
83
86
  end
84
87
  @system_message = system_message
85
88
  @system_message_enabled = !@system_message.nil?
86
89
  if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
87
- compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context) : nil
90
+ compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths) : nil
88
91
  end
89
92
  @compaction_system_message = compaction_system_message
90
93
  @workspace_agents_mtime = workspace_agents_mtime
@@ -188,10 +191,10 @@ module Kward
188
191
  return nil unless @system_message_enabled
189
192
 
190
193
  @last_plugin_prompt_context = plugin_prompt_context
191
- replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context)
194
+ replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths)
192
195
  @system_message = replacement
193
196
  @on_system_message_change&.call(replacement)
194
- @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context)
197
+ @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, execution_profile_context: @execution_profile_context, project_skill_paths: @project_skill_paths)
195
198
  @workspace_agents_mtime = workspace_agents_mtime
196
199
  @system_prompt_sources_fingerprint = ConfigFiles.system_prompt_sources_fingerprint
197
200
  replacement
data/lib/kward/ekwsh.rb CHANGED
@@ -13,7 +13,7 @@ module Kward
13
13
  class Ekwsh
14
14
  Result = Struct.new(:output, :exit_status, :exit_shell, :clear, :open_editor_path, :interactive_command, :streamed, keyword_init: true)
15
15
  Completion = Struct.new(:range, :replacement, :candidates, keyword_init: true)
16
- BUILTINS = %w[alias cd pwd export unset unalias clear exit logout pty].freeze
16
+ BUILTINS = %w[alias capture cd pwd export unset unalias clear exit logout pty].freeze
17
17
  DEFAULT_SHELL = "/bin/sh"
18
18
  DEFAULT_TIMEOUT_SECONDS = 300
19
19
  DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
@@ -72,6 +72,23 @@ module Kward
72
72
  Completion.new(range: token[:range], replacement: replacement, candidates: candidates)
73
73
  end
74
74
 
75
+ def expand_alias(command, interactive: false)
76
+ words = shell_words(command)
77
+ return command if words.empty? || BUILTINS.include?(words.first)
78
+ return command unless @aliases[words.first]
79
+
80
+ rest = command.sub(/\A\s*#{Regexp.escape(words.first)}\b\s*/, "")
81
+ expanded = [@aliases.fetch(words.first), rest].reject(&:empty?).join(" ")
82
+ interactive ? expanded.sub(/\A\s*(?:capture|pty)(?:\s+|\z)/, "") : expanded
83
+ rescue ArgumentError
84
+ command
85
+ end
86
+
87
+ def editor_command_result(command, display_command: command)
88
+ expanded_command = expand_alias(command, interactive: true)
89
+ kward_command_result(expanded_command, display_command: display_command)
90
+ end
91
+
75
92
  private
76
93
 
77
94
  def configure_rbenv_environment
@@ -276,7 +293,7 @@ module Kward
276
293
  Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
277
294
  end
278
295
 
279
- def builtin_result(command, display_command: command)
296
+ def builtin_result(command, display_command: command, cancellation: nil, &block)
280
297
  words = shell_words(command)
281
298
  return nil if words.empty?
282
299
  assignment_result = persist_assignments(display_command, words)
@@ -297,6 +314,8 @@ module Kward
297
314
  unset_variables(display_command, words)
298
315
  when "clear"
299
316
  Result.new(output: "", exit_status: 0, clear: true)
317
+ when "capture"
318
+ captured_command_result(command, display_command: display_command, cancellation: cancellation, &block)
300
319
  when "pty"
301
320
  interactive_pty_result(command, display_command: display_command)
302
321
  else
@@ -306,13 +325,26 @@ module Kward
306
325
  Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
307
326
  end
308
327
 
328
+ def captured_command_result(command, display_command:, cancellation: nil, &block)
329
+ captured_command = command.sub(/\A\s*capture(?:\s+|\z)/, "")
330
+ if captured_command.empty?
331
+ return Result.new(output: "#{command_echo(display_command)}Usage: capture <command>\n", exit_status: 2)
332
+ end
333
+
334
+ execute(captured_command, display_command: display_command, cancellation: cancellation, &block)
335
+ end
336
+
309
337
  def interactive_pty_result(command, display_command: command)
310
338
  interactive_command = command.sub(/\A\s*pty(?:\s+|\z)/, "")
311
339
  if interactive_command.empty?
312
340
  return Result.new(output: "#{command_echo(display_command)}Usage: pty <command>\n", exit_status: 2)
313
341
  end
314
342
 
315
- Result.new(output: "#{command_echo(display_command)}[interactive PTY session started]\n", exit_status: 0, interactive_command: interactive_command)
343
+ interactive_command_result(interactive_command, display_command: display_command)
344
+ end
345
+
346
+ def interactive_command_result(command, display_command: command)
347
+ Result.new(output: command_echo(display_command), exit_status: 0, interactive_command: command)
316
348
  end
317
349
 
318
350
  def shell_words(command)
@@ -361,29 +393,18 @@ module Kward
361
393
  name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_-]*\z/) && !BUILTINS.include?(name.to_s)
362
394
  end
363
395
 
364
- def expand_alias(command)
365
- words = shell_words(command)
366
- return command if words.empty? || BUILTINS.include?(words.first)
367
- return command unless @aliases[words.first]
368
-
369
- rest = command.sub(/\A\s*#{Regexp.escape(words.first)}\b\s*/, "")
370
- [@aliases.fetch(words.first), rest].reject(&:empty?).join(" ")
371
- rescue ArgumentError
372
- command
373
- end
374
-
375
396
  def run_expanded_command(command, cancellation: nil, &block)
376
397
  expanded_command = expand_alias(command)
377
398
  exit_result = exit_result(expanded_command, display_command: command)
378
399
  return exit_result if exit_result
379
400
 
380
- builtin_result = builtin_result(expanded_command, display_command: command)
401
+ builtin_result = builtin_result(expanded_command, display_command: command, cancellation: cancellation, &block)
381
402
  return builtin_result if builtin_result
382
403
 
383
404
  kward_result = kward_command_result(expanded_command, display_command: command)
384
405
  return kward_result if kward_result
385
406
 
386
- execute(expanded_command, display_command: command, cancellation: cancellation, &block)
407
+ interactive_command_result(expanded_command, display_command: command)
387
408
  end
388
409
 
389
410
  def kward_command_result(command, display_command: command)
@@ -9,7 +9,7 @@ module Kward
9
9
  # This is intentionally low level: UI orchestration decides when terminal
10
10
  # ownership is handed to the child process and how the result is presented.
11
11
  class InteractivePtyRunner
12
- Result = Struct.new(:exit_status, keyword_init: true)
12
+ Result = Struct.new(:exit_status, :input_forwarded, keyword_init: true)
13
13
 
14
14
  READ_SIZE = 4096
15
15
 
@@ -18,34 +18,27 @@ module Kward
18
18
  @window_size = nil
19
19
  end
20
20
 
21
- def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, output: $stdout)
21
+ def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, output: $stdout, &block)
22
22
  pid = nil
23
23
  status = nil
24
+ writer = nil
25
+ input_forwarded = false
24
26
 
25
- PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, writer, child_pid|
27
+ PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, child_writer, child_pid|
26
28
  pid = child_pid
29
+ writer = child_writer
27
30
  update_window_size(reader, pid)
28
31
  with_raw_input(input) do
29
32
  drain_initial_input(input, writer)
30
- loop do
31
- update_window_size(reader, pid)
32
- readable = IO.select([reader, input], nil, nil, 0.02)&.first || []
33
- forward_pty_output(reader, output) if readable.include?(reader)
34
- forward_input(input, writer) if readable.include?(input)
35
- if (finished_status = finished_status(pid))
36
- status = finished_status
37
- break
38
- end
39
- rescue Errno::EIO, IOError
40
- break
41
- end
33
+ status, input_forwarded = forward_io(reader, writer, pid, input, output, &block)
42
34
  end
43
35
  status ||= wait_for_status(pid)
44
- ensure
45
- writer&.close unless writer&.closed?
46
36
  end
47
37
 
48
- Result.new(exit_status: exit_status(status))
38
+ Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded)
39
+ ensure
40
+ writer&.close unless writer&.closed?
41
+ terminate_and_reap(pid) if pid && status.nil?
49
42
  end
50
43
 
51
44
  private
@@ -53,8 +46,14 @@ module Kward
53
46
  def with_raw_input(input)
54
47
  return yield unless input.respond_to?(:raw)
55
48
 
56
- input.raw { yield }
49
+ yielded = false
50
+ input.raw do
51
+ yielded = true
52
+ yield
53
+ end
57
54
  rescue Errno::ENOTTY
55
+ raise if yielded
56
+
58
57
  yield
59
58
  end
60
59
 
@@ -70,22 +69,67 @@ module Kward
70
69
  nil
71
70
  end
72
71
 
72
+ def forward_io(reader, writer, pid, input, output, &block)
73
+ input_open = true
74
+ input_forwarded = false
75
+ loop do
76
+ update_window_size(reader, pid)
77
+ readers = [reader]
78
+ readers << input if input_open
79
+ readable = IO.select(readers, nil, nil, 0.02)&.first || []
80
+ forward_pty_output(reader, output, &block) if readable.include?(reader)
81
+ if input_open && readable.include?(input)
82
+ input_open, forwarded = forward_input(input, writer)
83
+ input_forwarded ||= forwarded
84
+ end
85
+
86
+ status = finished_status(pid)
87
+ next unless status
88
+
89
+ return [finish_stopped_process(pid), input_forwarded] if status.stopped?
90
+
91
+ drain_pty_output(reader, output, &block)
92
+ return [status, input_forwarded]
93
+ rescue Errno::EIO
94
+ return [wait_for_status(pid), input_forwarded]
95
+ end
96
+ end
97
+
73
98
  def forward_pty_output(reader, output)
74
99
  chunk = reader.read_nonblock(READ_SIZE, exception: false)
75
100
  return if chunk.nil? || chunk == :wait_readable
76
101
 
77
102
  output.write(chunk)
78
103
  output.flush if output.respond_to?(:flush)
104
+ yield chunk if block_given?
105
+ end
106
+
107
+ def drain_pty_output(reader, output)
108
+ loop do
109
+ readable, = IO.select([reader], nil, nil, 0.02)
110
+ break unless readable
111
+
112
+ chunk = reader.read_nonblock(READ_SIZE, exception: false)
113
+ break if chunk.nil? || chunk == :wait_readable
114
+
115
+ output.write(chunk)
116
+ yield chunk if block_given?
117
+ end
118
+ output.flush if output.respond_to?(:flush)
119
+ rescue Errno::EIO
120
+ nil
79
121
  end
80
122
 
81
123
  def forward_input(input, writer)
82
124
  chunk = input.read_nonblock(READ_SIZE, exception: false)
83
- return if chunk.nil? || chunk == :wait_readable
125
+ return [false, false] if chunk.nil?
126
+ return [true, false] if chunk == :wait_readable
84
127
 
85
128
  writer.write(chunk)
86
129
  writer.flush
130
+ [true, true]
87
131
  rescue Errno::EIO, Errno::EPIPE, IOError
88
- nil
132
+ [false, false]
89
133
  end
90
134
 
91
135
  def update_window_size(reader, pid)
@@ -111,12 +155,18 @@ module Kward
111
155
  def finished_status(pid)
112
156
  return unless pid
113
157
 
114
- finished_pid, status = Process.wait2(pid, Process::WNOHANG)
158
+ finished_pid, status = Process.wait2(pid, Process::WNOHANG | Process::WUNTRACED)
115
159
  status if finished_pid
116
160
  rescue Errno::ECHILD
117
161
  nil
118
162
  end
119
163
 
164
+ def finish_stopped_process(pid)
165
+ signal_process("CONT", -pid) || signal_process("CONT", pid)
166
+ terminate_process_group(pid)
167
+ wait_for_status(pid)
168
+ end
169
+
120
170
  def wait_for_status(pid)
121
171
  return unless pid
122
172
 
@@ -134,6 +184,22 @@ module Kward
134
184
  1
135
185
  end
136
186
 
187
+ def terminate_and_reap(pid)
188
+ terminate_process_group(pid)
189
+ wait_for_status(pid)
190
+ end
191
+
192
+ def terminate_process_group(pid)
193
+ signal_process("TERM", -pid) || signal_process("TERM", pid)
194
+ deadline = Time.now + 0.2
195
+ while Time.now < deadline
196
+ return unless process_running?(pid)
197
+
198
+ sleep 0.02
199
+ end
200
+ signal_process("KILL", -pid) || signal_process("KILL", pid)
201
+ end
202
+
137
203
  def process_running?(pid)
138
204
  Process.kill(0, pid)
139
205
  true
@@ -256,11 +256,23 @@ module Kward
256
256
  def submit_input
257
257
  value = submitted_input
258
258
  add_history(composer_input)
259
- clear_finished_input_locked(reset_history: true)
259
+ if !@busy && value.strip.empty?
260
+ @output_io.flush
261
+ return value
262
+ end
263
+
264
+ clear_submitted_input_locked
260
265
  @output_io.flush
261
266
  value
262
267
  end
263
268
 
269
+ def clear_submitted_input_locked
270
+ self.composer_input = ""
271
+ self.composer_cursor = 0
272
+ @composer.clear_attachments
273
+ reset_history_search
274
+ end
275
+
264
276
  def clear_finished_input_locked(reset_history: false)
265
277
  if @busy
266
278
  clear_prompt_for_output_locked
@@ -827,6 +827,10 @@ module Kward
827
827
  modifier = sequence[:modifier]
828
828
  queue_pending_keys(sequence[:remaining]) if sequence[:remaining] && !sequence[:remaining].empty?
829
829
 
830
+ if ctrl_modifier?(modifier) && ctrl_code(code) == 113
831
+ return close_editor
832
+ end
833
+
830
834
  if ctrl_modifier?(modifier) && ctrl_code(code) == 102
831
835
  return editor_search_active? ? editor_search_append(key) : editor_search_begin
832
836
  end
@@ -36,6 +36,9 @@ module Kward
36
36
  return true if handle_mouse_reporting_key(key)
37
37
  return if handle_bracketed_paste_key(key)
38
38
 
39
+ completion_overlay_result = handle_completion_overlay_key(key)
40
+ return completion_overlay_result unless completion_overlay_result == false
41
+
39
42
  csi_result = handle_csi_u_key(key)
40
43
  return csi_result unless csi_result == false
41
44
  return if handle_shift_enter_key(key)
@@ -44,9 +47,6 @@ module Kward
44
47
  completion_result = handle_completion_provider_key(key)
45
48
  return completion_result unless completion_result == false
46
49
 
47
- reasoning_result = handle_reasoning_key_binding(key)
48
- return reasoning_result unless reasoning_result == false
49
-
50
50
  tab_result = handle_tab_key_binding(key)
51
51
  return tab_result unless tab_result == false
52
52
 
@@ -81,7 +81,7 @@ module Kward
81
81
  when :ctrl_y
82
82
  yank_kill_buffer
83
83
  when :ctrl_l
84
- redraw_screen_locked
84
+ request_transcript_redraw
85
85
  when :ctrl_r
86
86
  start_history_search
87
87
  when :left
@@ -212,9 +212,6 @@ module Kward
212
212
  return true if sequence == "\e" && (dismiss_file_overlay || dismiss_slash_overlay)
213
213
  return true if handle_shift_enter_key(sequence)
214
214
 
215
- reasoning_result = handle_reasoning_key_binding(sequence)
216
- return reasoning_result unless reasoning_result == false
217
-
218
215
  tab_result = handle_tab_key_binding(sequence)
219
216
  return tab_result unless tab_result == false
220
217
 
@@ -323,10 +320,10 @@ module Kward
323
320
  if ctrl_modifier?(modifier)
324
321
  shift_modifier?(modifier) ? { tab_action: :previous } : { tab_action: :next }
325
322
  elsif shift_modifier?(modifier)
326
- handle_reasoning_key_binding(key) || handle_tab_completion_key
323
+ handle_tab_completion_key
327
324
  else
328
325
  completion_result = handle_completion_provider_key("\t")
329
- completion_result == false ? handle_reasoning_key_binding("\t") || handle_tab_completion_key : completion_result
326
+ completion_result == false ? handle_tab_completion_key : completion_result
330
327
  end
331
328
  when 13
332
329
  if modifier == 2
@@ -447,7 +444,7 @@ module Kward
447
444
  when 107
448
445
  kill_line_after_cursor
449
446
  when 108
450
- redraw_screen_locked
447
+ request_transcript_redraw
451
448
  when 114
452
449
  start_history_search
453
450
  when 117
@@ -580,61 +577,99 @@ module Kward
580
577
 
581
578
  CTRL_TAB_SEQUENCES = TerminalKeys::CTRL_TAB
582
579
  CTRL_SHIFT_TAB_SEQUENCES = TerminalKeys::CTRL_SHIFT_TAB
583
- SHIFT_TAB_SEQUENCES = TerminalKeys::SHIFT_TAB
580
+
581
+ def handle_completion_overlay_key(key)
582
+ return false unless @completion_overlay
583
+
584
+ return handle_completion_provider_key("\t") if completion_tab_key?(key)
585
+
586
+ dismiss_completion_overlay
587
+ false
588
+ end
589
+
590
+ def completion_tab_key?(key)
591
+ return true if key == "\t"
592
+
593
+ sequence = parse_csi_u_key(key)
594
+ return false unless sequence && sequence[:code] == 9
595
+
596
+ completion = !ctrl_modifier?(sequence[:modifier]) && !shift_modifier?(sequence[:modifier])
597
+ queue_pending_keys(sequence[:remaining]) if completion && sequence[:remaining] && !sequence[:remaining].empty?
598
+ completion
599
+ end
600
+
601
+ def dismiss_completion_overlay
602
+ @completion_overlay = nil
603
+ @completion_cycle = nil
604
+ end
584
605
 
585
606
  def handle_completion_provider_key(key)
586
607
  return false unless key == "\t" && @completion_provider
587
608
 
609
+ if (cycle = matching_completion_cycle)
610
+ cycle_completion(cycle)
611
+ return true
612
+ end
613
+
588
614
  result = @completion_provider.call(composer_input, composer_cursor)
615
+ return false if result == false
589
616
  return true unless result
590
617
 
591
618
  apply_completion_result(result)
592
619
  true
593
620
  end
594
621
 
622
+ def matching_completion_cycle
623
+ cycle = @completion_cycle
624
+ return nil unless cycle
625
+ return cycle if cycle[:input] == composer_input && cycle[:cursor] == composer_cursor
626
+
627
+ @completion_cycle = nil
628
+ @completion_overlay = nil
629
+ nil
630
+ end
631
+
632
+ def cycle_completion(cycle)
633
+ cycle[:index] = (cycle[:index] + 1) % cycle[:candidates].length
634
+ apply_completion_cycle_candidate(cycle)
635
+ end
636
+
595
637
  def apply_completion_result(result)
596
638
  range = result[:range] || result["range"] || result.range
597
639
  replacement = result[:replacement] || result["replacement"] || result.replacement
598
- candidates = result[:candidates] || result["candidates"] || result.candidates
640
+ candidates = (result[:candidates] || result["candidates"] || result.candidates).to_a
599
641
  original = composer_input
600
642
  before = original[0...range.begin].to_s
601
643
  after = original[range.end..].to_s
644
+
645
+ if candidates.length > 1
646
+ @completion_cycle = {
647
+ candidates: candidates.map(&:to_s),
648
+ index: 0,
649
+ before: before,
650
+ after: after
651
+ }
652
+ apply_completion_cycle_candidate(@completion_cycle)
653
+ show_completion_overlay(candidates)
654
+ return
655
+ end
656
+
657
+ @completion_cycle = nil
602
658
  self.composer_input = "#{before}#{replacement}#{after}"
603
659
  self.composer_cursor = before.length + replacement.to_s.length
604
- show_completion_candidates(candidates, replacement) if candidates.to_a.length > 1 && replacement.to_s == original[range]
605
660
  end
606
661
 
607
- def show_completion_candidates(candidates, replacement)
608
- lines = candidates.to_a.first(40)
609
- text = ["completions:", *lines.map { |candidate| " #{candidate}" }].join("\n")
610
- write_completion_transcript_locked(text)
662
+ def show_completion_overlay(candidates)
663
+ @completion_overlay = { candidates: candidates.map(&:to_s), index: 0 }
611
664
  end
612
665
 
613
- def write_completion_transcript_locked(text)
614
- with_synchronized_output_locked do
615
- clear_prompt_for_output_locked
616
- write_transcript_text_locked("\n#{text}\n")
617
- render_prompt_after_output_locked
618
- end
619
- end
620
-
621
- def handle_reasoning_key_binding(key)
622
- return false if @busy || @select_state || @question_state
623
- return false if file_overlay_visible? || slash_overlay_visible?
624
- return false if @slash_overlay_dismissed_input && @slash_overlay_dismissed_input == composer_input
625
- mention_token = active_file_mention_token
626
- open_token = active_file_open_token
627
- return false if mention_token && @file_overlay_dismissed_token == mention_token
628
- return false if open_token && @file_open_dismissed_token == open_token
629
-
630
- case key
631
- when "\t"
632
- { reasoning_action: :next }
633
- when *SHIFT_TAB_SEQUENCES
634
- { reasoning_action: :previous }
635
- else
636
- false
637
- end
666
+ def apply_completion_cycle_candidate(cycle)
667
+ replacement = cycle[:candidates][cycle[:index]]
668
+ self.composer_input = "#{cycle[:before]}#{replacement}#{cycle[:after]}"
669
+ self.composer_cursor = cycle[:before].length + replacement.length
670
+ @completion_overlay[:index] = cycle[:index] if @completion_overlay
671
+ cycle[:input] = composer_input
672
+ cycle[:cursor] = composer_cursor
638
673
  end
639
674
 
640
675
  def handle_tab_key_binding(key)
@@ -710,7 +745,7 @@ module Kward
710
745
  when TerminalKeys::CTRL_K
711
746
  kill_line_after_cursor
712
747
  when TerminalKeys::CTRL_L
713
- redraw_screen_locked
748
+ request_transcript_redraw
714
749
  when TerminalKeys::CTRL_U
715
750
  kill_line_before_cursor
716
751
  when TerminalKeys::CTRL_W