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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/Gemfile.lock +2 -2
- data/README.md +2 -2
- data/Rakefile +44 -4
- data/doc/composer.md +6 -9
- data/doc/configuration.md +13 -3
- data/doc/files.md +8 -2
- data/doc/permissions.md +1 -1
- data/doc/releasing.md +71 -38
- data/doc/rpc.md +2 -1
- data/doc/sandboxing.md +4 -4
- data/doc/security.md +6 -9
- data/doc/shell.md +161 -196
- data/doc/skills.md +17 -5
- data/doc/tabs.md +1 -1
- data/doc/usage.md +6 -5
- data/kward.gemspec +1 -1
- data/lib/kward/adaptive_pty_output_sink.rb +183 -0
- data/lib/kward/ansi.rb +9 -1
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +5 -1
- data/lib/kward/cli/interactive_turn.rb +1 -1
- data/lib/kward/cli/plugins.rb +1 -1
- data/lib/kward/cli/project_skills.rb +99 -0
- data/lib/kward/cli/project_skills_commands.rb +87 -0
- data/lib/kward/cli/prompt_interface.rb +18 -4
- data/lib/kward/cli/rendering.rb +41 -3
- data/lib/kward/cli/runtime_helpers.rb +222 -21
- data/lib/kward/cli/sessions.rb +6 -4
- data/lib/kward/cli/settings.rb +5 -13
- data/lib/kward/cli/slash_commands.rb +6 -0
- data/lib/kward/cli/tabs.rb +62 -5
- data/lib/kward/cli.rb +21 -1
- data/lib/kward/config_files.rb +9 -4
- data/lib/kward/conversation.rb +8 -5
- data/lib/kward/ekwsh.rb +37 -16
- data/lib/kward/image_attachments.rb +98 -15
- data/lib/kward/interactive_pty_runner.rb +102 -30
- data/lib/kward/prompt_interface/composer_controller.rb +15 -3
- data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/controller.rb +10 -6
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +1 -1
- data/lib/kward/prompt_interface/key_handler.rb +100 -43
- data/lib/kward/prompt_interface/overlay_renderer.rb +13 -0
- data/lib/kward/prompt_interface/project_browser.rb +162 -3
- data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/screen.rb +40 -15
- data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
- data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
- data/lib/kward/prompt_interface.rb +167 -44
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +6 -6
- data/lib/kward/pty_output_sink.rb +45 -0
- data/lib/kward/pty_transcript_normalizer.rb +93 -0
- data/lib/kward/rpc/server.rb +1 -0
- data/lib/kward/session_catalog.rb +87 -0
- data/lib/kward/session_store.rb +97 -7
- data/lib/kward/skills/registry.rb +52 -2
- data/lib/kward/skills/trust_coordinator.rb +45 -0
- data/lib/kward/skills/trust_store.rb +107 -0
- data/lib/kward/terminal_image_support.rb +116 -0
- data/lib/kward/terminal_sequences.rb +43 -0
- data/lib/kward/version.rb +1 -1
- metadata +11 -4
- data/.github/workflows/ci.yml +0 -48
- data/.github/workflows/pages.yml +0 -48
data/lib/kward/conversation.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
391
|
+
TerminalImageSupport.iterm2_hint?(env)
|
|
317
392
|
end
|
|
318
393
|
|
|
319
394
|
def kitty_image_protocol?(env)
|
|
320
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
330
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
+
forwarded
|
|
71
77
|
end
|
|
72
78
|
|
|
73
|
-
def
|
|
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
|
-
|
|
78
|
-
|
|
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?
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
992
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
2243
|
-
|
|
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
|
|