kward 0.80.1 → 0.82.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +2 -2
  5. data/Rakefile +44 -4
  6. data/doc/composer.md +6 -9
  7. data/doc/configuration.md +13 -3
  8. data/doc/files.md +8 -2
  9. data/doc/permissions.md +1 -1
  10. data/doc/releasing.md +71 -38
  11. data/doc/rpc.md +2 -1
  12. data/doc/sandboxing.md +4 -4
  13. data/doc/security.md +6 -9
  14. data/doc/shell.md +161 -196
  15. data/doc/skills.md +17 -5
  16. data/doc/tabs.md +1 -1
  17. data/doc/usage.md +6 -5
  18. data/kward.gemspec +1 -1
  19. data/lib/kward/adaptive_pty_output_sink.rb +183 -0
  20. data/lib/kward/ansi.rb +9 -1
  21. data/lib/kward/cli/commands.rb +7 -0
  22. data/lib/kward/cli/git.rb +5 -1
  23. data/lib/kward/cli/interactive_turn.rb +1 -1
  24. data/lib/kward/cli/plugins.rb +1 -1
  25. data/lib/kward/cli/project_skills.rb +99 -0
  26. data/lib/kward/cli/project_skills_commands.rb +87 -0
  27. data/lib/kward/cli/prompt_interface.rb +18 -4
  28. data/lib/kward/cli/rendering.rb +41 -3
  29. data/lib/kward/cli/runtime_helpers.rb +222 -21
  30. data/lib/kward/cli/sessions.rb +6 -4
  31. data/lib/kward/cli/settings.rb +5 -13
  32. data/lib/kward/cli/slash_commands.rb +6 -0
  33. data/lib/kward/cli/tabs.rb +62 -5
  34. data/lib/kward/cli.rb +21 -1
  35. data/lib/kward/config_files.rb +9 -4
  36. data/lib/kward/conversation.rb +8 -5
  37. data/lib/kward/ekwsh.rb +37 -16
  38. data/lib/kward/image_attachments.rb +98 -15
  39. data/lib/kward/interactive_pty_runner.rb +102 -30
  40. data/lib/kward/prompt_interface/composer_controller.rb +15 -3
  41. data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
  42. data/lib/kward/prompt_interface/editor/controller.rb +10 -6
  43. data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
  44. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  45. data/lib/kward/prompt_interface/git_prompt.rb +1 -1
  46. data/lib/kward/prompt_interface/key_handler.rb +100 -43
  47. data/lib/kward/prompt_interface/overlay_renderer.rb +13 -0
  48. data/lib/kward/prompt_interface/project_browser.rb +162 -3
  49. data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
  50. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  51. data/lib/kward/prompt_interface/screen.rb +40 -15
  52. data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
  53. data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
  54. data/lib/kward/prompt_interface.rb +167 -44
  55. data/lib/kward/prompts/commands.rb +2 -0
  56. data/lib/kward/prompts.rb +6 -6
  57. data/lib/kward/pty_output_sink.rb +45 -0
  58. data/lib/kward/pty_transcript_normalizer.rb +93 -0
  59. data/lib/kward/rpc/server.rb +1 -0
  60. data/lib/kward/session_catalog.rb +87 -0
  61. data/lib/kward/session_store.rb +97 -7
  62. data/lib/kward/skills/registry.rb +52 -2
  63. data/lib/kward/skills/trust_coordinator.rb +45 -0
  64. data/lib/kward/skills/trust_store.rb +107 -0
  65. data/lib/kward/terminal_image_support.rb +116 -0
  66. data/lib/kward/terminal_sequences.rb +43 -0
  67. data/lib/kward/version.rb +1 -1
  68. metadata +11 -4
  69. data/.github/workflows/ci.yml +0 -48
  70. data/.github/workflows/pages.yml +0 -48
@@ -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)
@@ -1,8 +1,10 @@
1
1
  require "base64"
2
2
  require "cgi/escape"
3
+ require "open3"
3
4
  require "shellwords"
4
5
  require "tmpdir"
5
6
  require "uri"
7
+ require_relative "terminal_image_support"
6
8
 
7
9
  # Namespace for the Kward CLI agent runtime.
8
10
  module Kward
@@ -291,6 +293,71 @@ module Kward
291
293
  MIME_TYPES.key?(File.extname(path.to_s).downcase)
292
294
  end
293
295
 
296
+ def image_part_from_path(path)
297
+ expanded_path = File.expand_path(path.to_s)
298
+ return nil unless File.file?(expanded_path)
299
+
300
+ media_type = mime_type(expanded_path)
301
+ return nil unless media_type
302
+
303
+ size = File.size(expanded_path)
304
+ return nil if size > MAX_IMAGE_BYTES
305
+
306
+ {
307
+ type: "image",
308
+ media_type: media_type,
309
+ data: Base64.strict_encode64(File.binread(expanded_path)),
310
+ path: expanded_path,
311
+ size_bytes: size
312
+ }
313
+ rescue SystemCallError
314
+ nil
315
+ end
316
+
317
+ def terminal_image_part(part, protocol)
318
+ return part unless protocol == :kitty
319
+ return part if image_media_type(part) == "image/png"
320
+
321
+ convert_image_part_to_png(part)
322
+ end
323
+
324
+ def png_dimensions(part)
325
+ encoded = part[:data] || part["data"]
326
+ header = Base64.strict_decode64(encoded.to_s[0, 32])
327
+ return nil unless header.start_with?("\x89PNG\r\n\x1A\n".b)
328
+ return nil unless header.byteslice(12, 4) == "IHDR"
329
+
330
+ width, height = header.byteslice(16, 8).unpack("NN")
331
+ return nil unless width.positive? && height.positive?
332
+
333
+ [width, height]
334
+ rescue ArgumentError, NoMethodError
335
+ nil
336
+ end
337
+
338
+ def convert_image_part_to_png(part)
339
+ path = part[:path] || part["path"]
340
+ return nil unless path && File.file?(path)
341
+
342
+ stdout, _stderr, status = Open3.capture3(
343
+ "magick",
344
+ "-limit", "memory", "128MiB",
345
+ "-limit", "map", "256MiB",
346
+ "#{path}[0]",
347
+ "png:-"
348
+ )
349
+ return nil unless status.success?
350
+ return nil if stdout.empty? || stdout.bytesize > MAX_IMAGE_BYTES
351
+
352
+ part.merge(
353
+ media_type: "image/png",
354
+ data: Base64.strict_encode64(stdout),
355
+ size_bytes: stdout.bytesize
356
+ )
357
+ rescue SystemCallError
358
+ nil
359
+ end
360
+
294
361
  def mime_type(path)
295
362
  MIME_TYPES[File.extname(path.to_s).downcase]
296
363
  end
@@ -300,36 +367,52 @@ module Kward
300
367
  "data:#{media_type};base64,#{part[:data] || part["data"]}"
301
368
  end
302
369
 
303
- def terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, env: ENV)
370
+ def terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, height: nil, protocol: nil, image_id: nil, move_cursor: true, env: ENV)
304
371
  data = part[:data] || part["data"]
305
372
  return nil if data.to_s.empty?
306
373
 
374
+ protocol ||= terminal_image_protocol(env)
307
375
  name = part[:path] || part["path"]
308
- if iterm_image_protocol?(env)
309
- iterm_image_sequence(data, name, width)
310
- elsif kitty_image_protocol?(env)
311
- kitty_image_sequence(data, name, width)
376
+ case protocol
377
+ when :iterm2
378
+ iterm_image_sequence(data, name, width, height, env: env)
379
+ when :kitty
380
+ return nil unless image_media_type(part) == "image/png"
381
+
382
+ kitty_image_sequence(data, name, width, height, image_id, move_cursor: move_cursor, env: env)
312
383
  end
313
384
  end
314
385
 
386
+ def terminal_image_protocol(env)
387
+ TerminalImageSupport.static_protocol(env)
388
+ end
389
+
315
390
  def iterm_image_protocol?(env)
316
- env["TERM_PROGRAM"] == "iTerm.app"
391
+ TerminalImageSupport.iterm2_hint?(env)
317
392
  end
318
393
 
319
394
  def kitty_image_protocol?(env)
320
- env["KITTY_WINDOW_ID"].to_s != "" || env["TERM"].to_s.include?("kitty") || env["TERM_PROGRAM"] == "WezTerm"
395
+ TerminalImageSupport.kitty_hint?(env)
396
+ end
397
+
398
+ def iterm_image_sequence(data, name, width, height = nil, env: ENV)
399
+ sequence = TerminalSequences.iterm2_image(data, name: name, width: width, height: height)
400
+ env["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
321
401
  end
322
402
 
323
- def iterm_image_sequence(data, name, width)
324
- params = ["inline=1", "preserveAspectRatio=1", "width=#{width}"]
325
- params << "name=#{Base64.strict_encode64(File.basename(name))}" if name
326
- "\e]1337;File=#{params.join(";")}:#{data}\a"
403
+ def kitty_image_sequence(data, _name, width, height = nil, image_id = nil, move_cursor: true, env: ENV)
404
+ sequence = TerminalSequences.kitty_graphics(
405
+ data,
406
+ image_id: image_id,
407
+ columns: width,
408
+ rows: height,
409
+ move_cursor: move_cursor
410
+ )
411
+ env["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
327
412
  end
328
413
 
329
- def kitty_image_sequence(data, name, width)
330
- params = ["inline=1", "preserveAspectRatio=1", "width=#{width}"]
331
- params << "name=#{Base64.strict_encode64(File.basename(name))}" if name
332
- "\e_G#{params.join(";")}:#{data}\e\\"
414
+ def image_media_type(part)
415
+ part[:media_type] || part["media_type"] || part[:mimeType] || part["mimeType"]
333
416
  end
334
417
  end
335
418
  end
@@ -2,14 +2,15 @@ require "io/console"
2
2
  require "pty"
3
3
  require "timeout"
4
4
  require_relative "local_pty_command_runner"
5
+ require_relative "pty_output_sink"
5
6
 
6
7
  # Namespace for the Kward CLI agent runtime.
7
8
  module Kward
8
- # Runs a command in a PTY while forwarding caller-owned input and output IOs.
9
+ # Runs a command in a PTY while forwarding caller-owned input and output sink.
9
10
  # This is intentionally low level: UI orchestration decides when terminal
10
11
  # ownership is handed to the child process and how the result is presented.
11
12
  class InteractivePtyRunner
12
- Result = Struct.new(:exit_status, keyword_init: true)
13
+ Result = Struct.new(:exit_status, :input_forwarded, keyword_init: true)
13
14
 
14
15
  READ_SIZE = 4096
15
16
 
@@ -18,34 +19,29 @@ module Kward
18
19
  @window_size = nil
19
20
  end
20
21
 
21
- def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, output: $stdout)
22
+ def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, sink:)
22
23
  pid = nil
23
24
  status = nil
25
+ writer = nil
26
+ input_forwarded = false
24
27
 
25
- PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, writer, child_pid|
28
+ PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, child_writer, child_pid|
26
29
  pid = child_pid
30
+ writer = child_writer
27
31
  update_window_size(reader, pid)
28
32
  with_raw_input(input) do
29
- 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
+ input_forwarded = drain_initial_input(input, writer, sink)
34
+ status, forwarded = forward_io(reader, writer, pid, input, sink)
35
+ input_forwarded ||= forwarded
42
36
  end
37
+ sink.finish if sink.respond_to?(:finish)
43
38
  status ||= wait_for_status(pid)
44
- ensure
45
- writer&.close unless writer&.closed?
46
39
  end
47
40
 
48
- Result.new(exit_status: exit_status(status))
41
+ Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded)
42
+ ensure
43
+ writer&.close unless writer&.closed?
44
+ terminate_and_reap(pid) if pid && status.nil?
49
45
  end
50
46
 
51
47
  private
@@ -53,39 +49,93 @@ module Kward
53
49
  def with_raw_input(input)
54
50
  return yield unless input.respond_to?(:raw)
55
51
 
56
- input.raw { yield }
52
+ yielded = false
53
+ input.raw do
54
+ yielded = true
55
+ yield
56
+ end
57
57
  rescue Errno::ENOTTY
58
+ raise if yielded
59
+
58
60
  yield
59
61
  end
60
62
 
61
- def drain_initial_input(input, writer)
63
+ def drain_initial_input(input, writer, sink)
64
+ forwarded = false
62
65
  loop do
63
66
  chunk = input.read_nonblock(READ_SIZE, exception: false)
64
67
  break if chunk.nil? || chunk == :wait_readable
65
68
 
69
+ sink.input_forwarded if sink.respond_to?(:input_forwarded)
66
70
  writer.write(chunk)
71
+ forwarded = true
67
72
  end
68
73
  writer.flush
74
+ forwarded
69
75
  rescue Errno::EIO, Errno::EPIPE, IOError
70
- nil
76
+ forwarded
71
77
  end
72
78
 
73
- def forward_pty_output(reader, output)
79
+ def forward_io(reader, writer, pid, input, sink)
80
+ input_open = true
81
+ input_forwarded = false
82
+ loop do
83
+ update_window_size(reader, pid)
84
+ readers = [reader]
85
+ readers << input if input_open
86
+ readable = IO.select(readers, nil, nil, 0.02)&.first || []
87
+ forward_pty_output(reader, sink) if readable.include?(reader)
88
+ if input_open && readable.include?(input)
89
+ input_open, forwarded = forward_input(input, writer, sink)
90
+ input_forwarded ||= forwarded
91
+ end
92
+
93
+ status = finished_status(pid)
94
+ next unless status
95
+
96
+ return [finish_stopped_process(pid), input_forwarded] if status.stopped?
97
+
98
+ drain_pty_output(reader, sink)
99
+ return [status, input_forwarded]
100
+ rescue Errno::EIO
101
+ return [wait_for_status(pid), input_forwarded]
102
+ end
103
+ end
104
+
105
+ def forward_pty_output(reader, sink)
74
106
  chunk = reader.read_nonblock(READ_SIZE, exception: false)
75
107
  return if chunk.nil? || chunk == :wait_readable
76
108
 
77
- output.write(chunk)
78
- output.flush if output.respond_to?(:flush)
109
+ sink.write(chunk)
110
+ sink.flush if sink.respond_to?(:flush)
111
+ end
112
+
113
+ def drain_pty_output(reader, sink)
114
+ loop do
115
+ readable, = IO.select([reader], nil, nil, 0.02)
116
+ break unless readable
117
+
118
+ chunk = reader.read_nonblock(READ_SIZE, exception: false)
119
+ break if chunk.nil? || chunk == :wait_readable
120
+
121
+ sink.write(chunk)
122
+ end
123
+ sink.flush if sink.respond_to?(:flush)
124
+ rescue Errno::EIO
125
+ nil
79
126
  end
80
127
 
81
- def forward_input(input, writer)
128
+ def forward_input(input, writer, sink)
82
129
  chunk = input.read_nonblock(READ_SIZE, exception: false)
83
- return if chunk.nil? || chunk == :wait_readable
130
+ return [false, false] if chunk.nil?
131
+ return [true, false] if chunk == :wait_readable
84
132
 
133
+ sink.input_forwarded if sink.respond_to?(:input_forwarded)
85
134
  writer.write(chunk)
86
135
  writer.flush
136
+ [true, true]
87
137
  rescue Errno::EIO, Errno::EPIPE, IOError
88
- nil
138
+ [false, false]
89
139
  end
90
140
 
91
141
  def update_window_size(reader, pid)
@@ -111,12 +161,18 @@ module Kward
111
161
  def finished_status(pid)
112
162
  return unless pid
113
163
 
114
- finished_pid, status = Process.wait2(pid, Process::WNOHANG)
164
+ finished_pid, status = Process.wait2(pid, Process::WNOHANG | Process::WUNTRACED)
115
165
  status if finished_pid
116
166
  rescue Errno::ECHILD
117
167
  nil
118
168
  end
119
169
 
170
+ def finish_stopped_process(pid)
171
+ signal_process("CONT", -pid) || signal_process("CONT", pid)
172
+ terminate_process_group(pid)
173
+ wait_for_status(pid)
174
+ end
175
+
120
176
  def wait_for_status(pid)
121
177
  return unless pid
122
178
 
@@ -134,6 +190,22 @@ module Kward
134
190
  1
135
191
  end
136
192
 
193
+ def terminate_and_reap(pid)
194
+ terminate_process_group(pid)
195
+ wait_for_status(pid)
196
+ end
197
+
198
+ def terminate_process_group(pid)
199
+ signal_process("TERM", -pid) || signal_process("TERM", pid)
200
+ deadline = Time.now + 0.2
201
+ while Time.now < deadline
202
+ return unless process_running?(pid)
203
+
204
+ sleep 0.02
205
+ end
206
+ signal_process("KILL", -pid) || signal_process("KILL", pid)
207
+ end
208
+
137
209
  def process_running?(pid)
138
210
  Process.kill(0, pid)
139
211
  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)
260
- @output_io.flush
259
+ if !@busy && value.strip.empty?
260
+ flush_output_locked
261
+ return value
262
+ end
263
+
264
+ clear_submitted_input_locked
265
+ flush_output_locked
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
@@ -294,7 +306,7 @@ module Kward
294
306
 
295
307
  def exit_input
296
308
  clear_finished_input_locked
297
- @output_io.flush
309
+ flush_output_locked
298
310
  EXIT_INPUT
299
311
  end
300
312
 
@@ -265,6 +265,33 @@ module Kward
265
265
  @composer.cursor_logical_position
266
266
  end
267
267
 
268
+ def image_viewer_preview_rows(height, width: screen_width)
269
+ available_rows = max_project_browser_rows(height, width: width)
270
+ desired_rows = [(IMAGE_VIEWER_MAX_ROWS * image_viewer_zoom).round, 1].max
271
+ [available_rows, desired_rows].min
272
+ end
273
+
274
+ def image_viewer_rows(width, height: screen_height)
275
+ content_width = [width - 4, 1].max
276
+ preview_rows = image_viewer_preview_rows(height, width: width)
277
+ title = visible_truncate(" Image: #{@image_viewer_state[:display_path]} ", content_width)
278
+ rows = [
279
+ colored("╭", :primary_green) + title + colored("─" * [width - ANSI.strip(title).length - 2, 0].max, :primary_green) + colored("╮", :primary_green),
280
+ image_viewer_graphic_row(width, image_viewer_sequence(width, height))
281
+ ]
282
+ rows.concat(Array.new(preview_rows - 1) { box_content_row("", content_width) })
283
+ zoom = (image_viewer_zoom * 100).round
284
+ rows << footer_row(content_width, "Read-only image preview · #{zoom}% · +/- zoom · Esc/Q close")
285
+ rows << bottom_border(width)
286
+ rows
287
+ end
288
+
289
+ def image_viewer_graphic_row(width, sequence)
290
+ left_border = "#{colored("│", :primary_green)} "
291
+ right_border = "#{TerminalSequences.move_to_column(width)}#{colored("│", :primary_green)}"
292
+ "#{TTY::Cursor.clear_line}#{left_border}#{sequence}#{right_border}"
293
+ end
294
+
268
295
  end
269
296
  end
270
297
  end
@@ -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
@@ -988,8 +992,8 @@ module Kward
988
992
  return false if text.empty?
989
993
 
990
994
  @editor_state.push_kill(text)
991
- @output_io.print(TerminalSequences.osc52(text))
992
- @output_io.flush if @output_io.respond_to?(:flush)
995
+ print_output_locked(TerminalSequences.osc52(text))
996
+ flush_output_locked if @output_io.respond_to?(:flush)
993
997
  @editor_state.clear_selection
994
998
  @editor_state.status = "Copied selection"
995
999
  true
@@ -1099,16 +1103,16 @@ module Kward
1099
1103
  def enable_editor_mouse_reporting
1100
1104
  return if @editor_mouse_reporting_enabled
1101
1105
 
1102
- @output_io.print(TerminalSequences::MOUSE_REPORTING_ENABLE)
1103
- @output_io.flush if @output_io.respond_to?(:flush)
1106
+ print_output_locked(TerminalSequences::MOUSE_REPORTING_ENABLE)
1107
+ flush_output_locked if @output_io.respond_to?(:flush)
1104
1108
  @editor_mouse_reporting_enabled = true
1105
1109
  end
1106
1110
 
1107
1111
  def disable_editor_mouse_reporting(force: false)
1108
1112
  return unless force || @editor_mouse_reporting_enabled
1109
1113
 
1110
- @output_io.print(TerminalSequences::MOUSE_REPORTING_DISABLE)
1111
- @output_io.flush if @output_io.respond_to?(:flush)
1114
+ print_output_locked(TerminalSequences::MOUSE_REPORTING_DISABLE)
1115
+ flush_output_locked if @output_io.respond_to?(:flush)
1112
1116
  @editor_mouse_reporting_enabled = false
1113
1117
  end
1114
1118
 
@@ -240,8 +240,8 @@ module Kward
240
240
  end
241
241
 
242
242
  @editor_state.copy_for_kill_ring(range[0], range[1])
243
- @output_io.print(TerminalSequences.osc52(@editor_state.kill_buffer))
244
- @output_io.flush if @output_io.respond_to?(:flush)
243
+ print_output_locked(TerminalSequences.osc52(@editor_state.kill_buffer))
244
+ flush_output_locked if @output_io.respond_to?(:flush)
245
245
  @editor_state.clear_selection
246
246
  @editor_state.status = "Copied region"
247
247
  true
@@ -2239,8 +2239,8 @@ module Kward
2239
2239
  def vibe_copy_range(start_index, end_index, status, linewise: false)
2240
2240
  @editor_state.copy_range(start_index, end_index)
2241
2241
  @editor_state.vibe_kill_linewise = linewise
2242
- @output_io.print(TerminalSequences.osc52(@editor_state.kill_buffer))
2243
- @output_io.flush if @output_io.respond_to?(:flush)
2242
+ print_output_locked(TerminalSequences.osc52(@editor_state.kill_buffer))
2243
+ flush_output_locked if @output_io.respond_to?(:flush)
2244
2244
  @editor_state.status = status
2245
2245
  end
2246
2246
 
@@ -262,7 +262,7 @@ module Kward
262
262
  self.composer_cursor = 0
263
263
  @asking = true
264
264
  render_prompt_locked
265
- @output_io.flush
265
+ flush_output_locked
266
266
  end
267
267
  end
268
268