kward 0.72.0 → 0.73.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/.github/workflows/ci.yml +30 -0
- data/CHANGELOG.md +53 -0
- data/Gemfile.lock +2 -2
- data/doc/configuration.md +1 -1
- data/doc/editor.md +23 -2
- data/doc/git.md +1 -0
- data/doc/rpc.md +2 -2
- data/doc/shell.md +56 -10
- data/doc/usage.md +27 -1
- data/lib/kward/ansi.rb +62 -23
- data/lib/kward/cli/plugins.rb +1 -1
- data/lib/kward/cli/rendering.rb +4 -1
- data/lib/kward/cli/runtime_helpers.rb +141 -7
- data/lib/kward/cli/settings.rb +0 -1
- data/lib/kward/cli/slash_commands.rb +213 -0
- data/lib/kward/cli/tabs.rb +34 -4
- data/lib/kward/cli/tool_summaries.rb +6 -0
- data/lib/kward/cli.rb +4 -12
- data/lib/kward/clipboard.rb +2 -3
- data/lib/kward/compactor.rb +7 -19
- data/lib/kward/config_files.rb +26 -4
- data/lib/kward/ekwsh.rb +239 -42
- data/lib/kward/image_attachments.rb +3 -1
- data/lib/kward/interactive_pty_runner.rb +151 -0
- data/lib/kward/local_command_runner.rb +155 -0
- data/lib/kward/local_pty_command_runner.rb +171 -0
- data/lib/kward/model/context_usage.rb +2 -2
- data/lib/kward/model/payloads.rb +2 -5
- data/lib/kward/prompt_history.rb +5 -3
- data/lib/kward/prompt_interface/editor/auto_indent.rb +5 -4
- data/lib/kward/prompt_interface/editor/controller.rb +262 -62
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +21 -21
- data/lib/kward/prompt_interface/editor/modes/modern.rb +38 -37
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +23 -173
- data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +6 -5
- data/lib/kward/prompt_interface/editor/state.rb +28 -6
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +5 -3
- data/lib/kward/prompt_interface/git_prompt.rb +12 -23
- data/lib/kward/prompt_interface/interactive/controller.rb +1 -1
- data/lib/kward/prompt_interface/key_handler.rb +93 -51
- data/lib/kward/prompt_interface/question_prompt.rb +1 -6
- data/lib/kward/prompt_interface/screen.rb +3 -3
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -6
- data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
- data/lib/kward/prompt_interface.rb +87 -221
- data/lib/kward/prompts/commands.rb +4 -0
- data/lib/kward/rpc/memory_methods.rb +83 -0
- data/lib/kward/rpc/server.rb +130 -83
- data/lib/kward/rpc/session_manager.rb +10 -74
- data/lib/kward/rpc/tool_metadata.rb +11 -0
- data/lib/kward/rpc/transcript_normalizer.rb +4 -39
- data/lib/kward/scratchpad_runner.rb +56 -0
- data/lib/kward/session_diff.rb +20 -3
- data/lib/kward/session_naming.rb +11 -0
- data/lib/kward/terminal_keys.rb +84 -0
- data/lib/kward/terminal_sequences.rb +42 -0
- data/lib/kward/tools/context_for_task.rb +2 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +25 -0
- data/lib/kward/workers/job.rb +99 -0
- data/lib/kward/workers/queue_runner.rb +166 -0
- data/lib/kward/workers/queue_store.rb +112 -0
- data/lib/kward/workers.rb +3 -0
- data/lib/kward/workspace.rb +15 -63
- data/templates/default/fulldoc/html/css/kward.css +33 -0
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
- data/templates/default/fulldoc/html/setup.rb +1 -0
- data/templates/default/layout/html/layout.erb +19 -32
- metadata +15 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
require "io/console"
|
|
2
|
+
require "pty"
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "local_pty_command_runner"
|
|
5
|
+
|
|
6
|
+
# Namespace for the Kward CLI agent runtime.
|
|
7
|
+
module Kward
|
|
8
|
+
# Runs a command in a PTY while forwarding caller-owned input and output IOs.
|
|
9
|
+
# This is intentionally low level: UI orchestration decides when terminal
|
|
10
|
+
# ownership is handed to the child process and how the result is presented.
|
|
11
|
+
class InteractivePtyRunner
|
|
12
|
+
Result = Struct.new(:exit_status, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
READ_SIZE = 4096
|
|
15
|
+
|
|
16
|
+
def initialize(window_size_provider: nil)
|
|
17
|
+
@window_size_provider = window_size_provider
|
|
18
|
+
@window_size = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, output: $stdout)
|
|
22
|
+
pid = nil
|
|
23
|
+
status = nil
|
|
24
|
+
|
|
25
|
+
PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, writer, child_pid|
|
|
26
|
+
pid = child_pid
|
|
27
|
+
update_window_size(reader, pid)
|
|
28
|
+
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
|
|
42
|
+
end
|
|
43
|
+
status ||= wait_for_status(pid)
|
|
44
|
+
ensure
|
|
45
|
+
writer&.close unless writer&.closed?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Result.new(exit_status: exit_status(status))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def with_raw_input(input)
|
|
54
|
+
return yield unless input.respond_to?(:raw)
|
|
55
|
+
|
|
56
|
+
input.raw { yield }
|
|
57
|
+
rescue Errno::ENOTTY
|
|
58
|
+
yield
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def drain_initial_input(input, writer)
|
|
62
|
+
loop do
|
|
63
|
+
chunk = input.read_nonblock(READ_SIZE, exception: false)
|
|
64
|
+
break if chunk.nil? || chunk == :wait_readable
|
|
65
|
+
|
|
66
|
+
writer.write(chunk)
|
|
67
|
+
end
|
|
68
|
+
writer.flush
|
|
69
|
+
rescue Errno::EIO, Errno::EPIPE, IOError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def forward_pty_output(reader, output)
|
|
74
|
+
chunk = reader.read_nonblock(READ_SIZE, exception: false)
|
|
75
|
+
return if chunk.nil? || chunk == :wait_readable
|
|
76
|
+
|
|
77
|
+
output.write(chunk)
|
|
78
|
+
output.flush if output.respond_to?(:flush)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def forward_input(input, writer)
|
|
82
|
+
chunk = input.read_nonblock(READ_SIZE, exception: false)
|
|
83
|
+
return if chunk.nil? || chunk == :wait_readable
|
|
84
|
+
|
|
85
|
+
writer.write(chunk)
|
|
86
|
+
writer.flush
|
|
87
|
+
rescue Errno::EIO, Errno::EPIPE, IOError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def update_window_size(reader, pid)
|
|
92
|
+
window_size = terminal_window_size
|
|
93
|
+
return if window_size == @window_size
|
|
94
|
+
|
|
95
|
+
@window_size = window_size
|
|
96
|
+
reader.winsize = window_size
|
|
97
|
+
signal_process("WINCH", -pid) || signal_process("WINCH", pid)
|
|
98
|
+
rescue StandardError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def terminal_window_size
|
|
103
|
+
rows, columns = @window_size_provider ? @window_size_provider.call : IO.console&.winsize
|
|
104
|
+
rows = LocalPtyCommandRunner::DEFAULT_ROWS unless rows.to_i.positive?
|
|
105
|
+
columns = LocalPtyCommandRunner::DEFAULT_COLUMNS unless columns.to_i.positive?
|
|
106
|
+
[rows, columns]
|
|
107
|
+
rescue StandardError
|
|
108
|
+
[LocalPtyCommandRunner::DEFAULT_ROWS, LocalPtyCommandRunner::DEFAULT_COLUMNS]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def finished_status(pid)
|
|
112
|
+
return unless pid
|
|
113
|
+
|
|
114
|
+
finished_pid, status = Process.wait2(pid, Process::WNOHANG)
|
|
115
|
+
status if finished_pid
|
|
116
|
+
rescue Errno::ECHILD
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def wait_for_status(pid)
|
|
121
|
+
return unless pid
|
|
122
|
+
|
|
123
|
+
_, status = Process.wait2(pid)
|
|
124
|
+
status
|
|
125
|
+
rescue Errno::ECHILD
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def exit_status(status)
|
|
130
|
+
return 1 unless status
|
|
131
|
+
return status.exitstatus if status.exited?
|
|
132
|
+
return 128 + status.termsig if status.signaled?
|
|
133
|
+
|
|
134
|
+
1
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def process_running?(pid)
|
|
138
|
+
Process.kill(0, pid)
|
|
139
|
+
true
|
|
140
|
+
rescue Errno::ESRCH
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def signal_process(signal, pid)
|
|
145
|
+
Process.kill(signal, pid)
|
|
146
|
+
true
|
|
147
|
+
rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
|
|
148
|
+
false
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "thread"
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "cancellation"
|
|
5
|
+
|
|
6
|
+
# Namespace for the Kward CLI agent runtime.
|
|
7
|
+
module Kward
|
|
8
|
+
# Low-level local process runner with bounded capture, timeout, cancellation,
|
|
9
|
+
# and optional output streaming. Callers own command semantics and formatting.
|
|
10
|
+
class LocalCommandRunner
|
|
11
|
+
Result = Struct.new(:stdout, :stderr, :exit_status, :timed_out, :truncated, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
READ_SIZE = 4096
|
|
14
|
+
|
|
15
|
+
def initialize(timeout_seconds:, max_output_bytes:, terminate_on_output_limit: false)
|
|
16
|
+
@timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : 30
|
|
17
|
+
@max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : 128 * 1024
|
|
18
|
+
@terminate_on_output_limit = terminate_on_output_limit
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
|
|
22
|
+
cancellation&.raise_if_cancelled!
|
|
23
|
+
stdout_buffer = +""
|
|
24
|
+
stderr_buffer = +""
|
|
25
|
+
captured_bytes = 0
|
|
26
|
+
truncated = false
|
|
27
|
+
timed_out = false
|
|
28
|
+
queue = Queue.new
|
|
29
|
+
|
|
30
|
+
Open3.popen3(env.to_h, *command, chdir: cwd.to_s, pgroup: true) do |stdin, stdout, stderr, wait_thread|
|
|
31
|
+
stdin.close
|
|
32
|
+
readers = [
|
|
33
|
+
read_stream(stdout, :stdout, queue),
|
|
34
|
+
read_stream(stderr, :stderr, queue)
|
|
35
|
+
]
|
|
36
|
+
cancellation&.on_cancel { terminate_process_group(wait_thread.pid) }
|
|
37
|
+
|
|
38
|
+
status = wait_for_process(wait_thread, readers, queue, cancellation: cancellation) do |stream, chunk|
|
|
39
|
+
captured_bytes, truncated, captured_chunk = capture_chunk(
|
|
40
|
+
stream,
|
|
41
|
+
chunk,
|
|
42
|
+
stdout_buffer,
|
|
43
|
+
stderr_buffer,
|
|
44
|
+
captured_bytes,
|
|
45
|
+
truncated
|
|
46
|
+
)
|
|
47
|
+
block&.call(stream, captured_chunk) unless captured_chunk.empty?
|
|
48
|
+
terminate_process_group(wait_thread.pid) if truncated && @terminate_on_output_limit
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
join_readers(readers)
|
|
52
|
+
drain_queue(queue) do |stream, chunk|
|
|
53
|
+
captured_bytes, truncated, captured_chunk = capture_chunk(
|
|
54
|
+
stream,
|
|
55
|
+
chunk,
|
|
56
|
+
stdout_buffer,
|
|
57
|
+
stderr_buffer,
|
|
58
|
+
captured_bytes,
|
|
59
|
+
truncated
|
|
60
|
+
)
|
|
61
|
+
block&.call(stream, captured_chunk) unless captured_chunk.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Result.new(stdout: stdout_buffer, stderr: stderr_buffer, exit_status: status.exitstatus || 1, timed_out: false, truncated: truncated)
|
|
65
|
+
rescue Timeout::Error
|
|
66
|
+
timed_out = true
|
|
67
|
+
terminate_process_group(wait_thread.pid)
|
|
68
|
+
join_readers(readers)
|
|
69
|
+
Result.new(stdout: stdout_buffer, stderr: stderr_buffer, exit_status: nil, timed_out: timed_out, truncated: truncated)
|
|
70
|
+
ensure
|
|
71
|
+
readers&.each { |reader| reader.kill if reader&.alive? }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def read_stream(io, stream, queue)
|
|
78
|
+
Thread.new do
|
|
79
|
+
loop do
|
|
80
|
+
queue << [stream, io.readpartial(READ_SIZE)]
|
|
81
|
+
rescue EOFError
|
|
82
|
+
break
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def wait_for_process(wait_thread, readers, queue, cancellation:)
|
|
88
|
+
deadline = Time.now + @timeout_seconds
|
|
89
|
+
loop do
|
|
90
|
+
cancellation&.raise_if_cancelled!
|
|
91
|
+
drain_queue(queue) { |stream, chunk| yield(stream, chunk) }
|
|
92
|
+
if wait_thread.join(0.02)
|
|
93
|
+
cancellation&.raise_if_cancelled!
|
|
94
|
+
return wait_thread.value
|
|
95
|
+
end
|
|
96
|
+
raise Timeout::Error if Time.now >= deadline
|
|
97
|
+
end
|
|
98
|
+
rescue Cancellation::CancelledError
|
|
99
|
+
terminate_process_group(wait_thread.pid)
|
|
100
|
+
join_readers(readers)
|
|
101
|
+
raise
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def drain_queue(queue)
|
|
105
|
+
loop do
|
|
106
|
+
stream, chunk = queue.pop(true)
|
|
107
|
+
yield(stream, chunk)
|
|
108
|
+
rescue ThreadError
|
|
109
|
+
break
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def capture_chunk(stream, chunk, stdout_buffer, stderr_buffer, captured_bytes, truncated)
|
|
114
|
+
return [captured_bytes, truncated, ""] if truncated
|
|
115
|
+
|
|
116
|
+
remaining = @max_output_bytes - captured_bytes
|
|
117
|
+
if chunk.bytesize > remaining
|
|
118
|
+
truncated = true
|
|
119
|
+
chunk = remaining.positive? ? chunk.byteslice(0, remaining).to_s : ""
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
stream == :stderr ? stderr_buffer << chunk : stdout_buffer << chunk
|
|
123
|
+
[captured_bytes + chunk.bytesize, truncated, chunk]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def join_readers(readers)
|
|
127
|
+
readers.to_a.each { |reader| reader.join(0.1) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def terminate_process_group(pid)
|
|
131
|
+
signal_process("TERM", -pid) || signal_process("TERM", pid)
|
|
132
|
+
deadline = Time.now + 0.2
|
|
133
|
+
while Time.now < deadline
|
|
134
|
+
return unless process_running?(pid)
|
|
135
|
+
|
|
136
|
+
sleep 0.02
|
|
137
|
+
end
|
|
138
|
+
signal_process("KILL", -pid) || signal_process("KILL", pid)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def process_running?(pid)
|
|
142
|
+
Process.kill(0, pid)
|
|
143
|
+
true
|
|
144
|
+
rescue Errno::ESRCH
|
|
145
|
+
false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def signal_process(signal, pid)
|
|
149
|
+
Process.kill(signal, pid)
|
|
150
|
+
true
|
|
151
|
+
rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require "io/console"
|
|
2
|
+
require "pty"
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "cancellation"
|
|
5
|
+
require_relative "local_command_runner"
|
|
6
|
+
|
|
7
|
+
# Namespace for the Kward CLI agent runtime.
|
|
8
|
+
module Kward
|
|
9
|
+
# Low-level pseudo-terminal command runner with bounded capture, timeout,
|
|
10
|
+
# cancellation, and optional output streaming. This gives child processes a
|
|
11
|
+
# TTY without trying to emulate an interactive terminal.
|
|
12
|
+
class LocalPtyCommandRunner
|
|
13
|
+
Result = LocalCommandRunner::Result
|
|
14
|
+
|
|
15
|
+
READ_SIZE = 4096
|
|
16
|
+
DEFAULT_ROWS = 24
|
|
17
|
+
DEFAULT_COLUMNS = 80
|
|
18
|
+
|
|
19
|
+
def initialize(timeout_seconds:, max_output_bytes:, terminate_on_output_limit: false, window_size_provider: nil)
|
|
20
|
+
@timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : 30
|
|
21
|
+
@max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : 128 * 1024
|
|
22
|
+
@terminate_on_output_limit = terminate_on_output_limit
|
|
23
|
+
@window_size_provider = window_size_provider
|
|
24
|
+
@window_size = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run(*command, env: {}, cwd: Dir.pwd, cancellation: nil, &block)
|
|
28
|
+
cancellation&.raise_if_cancelled!
|
|
29
|
+
output = +""
|
|
30
|
+
captured_bytes = 0
|
|
31
|
+
truncated = false
|
|
32
|
+
timed_out = false
|
|
33
|
+
cancelled = false
|
|
34
|
+
pid = nil
|
|
35
|
+
status = nil
|
|
36
|
+
|
|
37
|
+
PTY.spawn(env.to_h, *command, chdir: cwd.to_s) do |reader, _writer, child_pid|
|
|
38
|
+
pid = child_pid
|
|
39
|
+
update_window_size(reader, pid)
|
|
40
|
+
cancellation&.on_cancel do
|
|
41
|
+
cancelled = true
|
|
42
|
+
terminate_process_group(pid)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
deadline = Time.now + @timeout_seconds
|
|
47
|
+
loop do
|
|
48
|
+
cancellation&.raise_if_cancelled!
|
|
49
|
+
raise Timeout::Error if Time.now >= deadline
|
|
50
|
+
|
|
51
|
+
update_window_size(reader, pid)
|
|
52
|
+
readable, = IO.select([reader], nil, nil, 0.02)
|
|
53
|
+
next unless readable
|
|
54
|
+
|
|
55
|
+
chunk = read_chunk(reader)
|
|
56
|
+
break if chunk.nil?
|
|
57
|
+
|
|
58
|
+
chunk = normalize_line_endings(chunk)
|
|
59
|
+
captured_bytes, truncated, captured_chunk = capture_chunk(chunk, output, captured_bytes, truncated)
|
|
60
|
+
block&.call(:stdout, captured_chunk) unless captured_chunk.empty?
|
|
61
|
+
terminate_process_group(pid) if truncated && @terminate_on_output_limit
|
|
62
|
+
end
|
|
63
|
+
rescue Errno::EIO
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
status = wait_for_status(pid)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
cancellation&.raise_if_cancelled! if cancelled
|
|
71
|
+
Result.new(stdout: output, stderr: "", exit_status: exit_status(status), timed_out: false, truncated: truncated)
|
|
72
|
+
rescue Timeout::Error
|
|
73
|
+
timed_out = true
|
|
74
|
+
terminate_process_group(pid) if pid
|
|
75
|
+
wait_for_status(pid) if pid
|
|
76
|
+
Result.new(stdout: output, stderr: "", exit_status: nil, timed_out: timed_out, truncated: truncated)
|
|
77
|
+
rescue Cancellation::CancelledError
|
|
78
|
+
terminate_process_group(pid) if pid
|
|
79
|
+
wait_for_status(pid) if pid
|
|
80
|
+
raise
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def update_window_size(reader, pid)
|
|
86
|
+
window_size = terminal_window_size
|
|
87
|
+
return if window_size == @window_size
|
|
88
|
+
|
|
89
|
+
@window_size = window_size
|
|
90
|
+
reader.winsize = window_size
|
|
91
|
+
signal_process("WINCH", -pid) || signal_process("WINCH", pid)
|
|
92
|
+
rescue StandardError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def terminal_window_size
|
|
97
|
+
rows, columns = @window_size_provider ? @window_size_provider.call : IO.console&.winsize
|
|
98
|
+
rows = DEFAULT_ROWS unless rows.to_i.positive?
|
|
99
|
+
columns = DEFAULT_COLUMNS unless columns.to_i.positive?
|
|
100
|
+
[rows, columns]
|
|
101
|
+
rescue StandardError
|
|
102
|
+
[DEFAULT_ROWS, DEFAULT_COLUMNS]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def read_chunk(reader)
|
|
106
|
+
reader.read_nonblock(READ_SIZE, exception: false).tap do |chunk|
|
|
107
|
+
return nil if chunk.nil?
|
|
108
|
+
return nil if chunk == :wait_readable
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def normalize_line_endings(chunk)
|
|
113
|
+
chunk.gsub("\r\r\n", "\n").gsub("\r\n", "\n")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def capture_chunk(chunk, output, captured_bytes, truncated)
|
|
117
|
+
return [captured_bytes, truncated, ""] if truncated
|
|
118
|
+
|
|
119
|
+
remaining = @max_output_bytes - captured_bytes
|
|
120
|
+
if chunk.bytesize > remaining
|
|
121
|
+
truncated = true
|
|
122
|
+
chunk = remaining.positive? ? chunk.byteslice(0, remaining).to_s : ""
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
output << chunk
|
|
126
|
+
[captured_bytes + chunk.bytesize, truncated, chunk]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def wait_for_status(pid)
|
|
130
|
+
return unless pid
|
|
131
|
+
|
|
132
|
+
_, status = Process.wait2(pid)
|
|
133
|
+
status
|
|
134
|
+
rescue Errno::ECHILD
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def exit_status(status)
|
|
139
|
+
return 1 unless status
|
|
140
|
+
return status.exitstatus if status.exited?
|
|
141
|
+
return 128 + status.termsig if status.signaled?
|
|
142
|
+
|
|
143
|
+
1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def terminate_process_group(pid)
|
|
147
|
+
signal_process("TERM", -pid) || signal_process("TERM", pid)
|
|
148
|
+
deadline = Time.now + 0.2
|
|
149
|
+
while Time.now < deadline
|
|
150
|
+
return unless process_running?(pid)
|
|
151
|
+
|
|
152
|
+
sleep 0.02
|
|
153
|
+
end
|
|
154
|
+
signal_process("KILL", -pid) || signal_process("KILL", pid)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def process_running?(pid)
|
|
158
|
+
Process.kill(0, pid)
|
|
159
|
+
true
|
|
160
|
+
rescue Errno::ESRCH
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def signal_process(signal, pid)
|
|
165
|
+
Process.kill(signal, pid)
|
|
166
|
+
true
|
|
167
|
+
rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -12,7 +12,7 @@ module Kward
|
|
|
12
12
|
def call(provider:, model:, context_window:, context_parts:)
|
|
13
13
|
return nil unless context_window
|
|
14
14
|
|
|
15
|
-
parts = redact_image_data(
|
|
15
|
+
parts = redact_image_data(stringify_top_level_keys(context_parts || {}))
|
|
16
16
|
return nil unless contains_session_content?(parts)
|
|
17
17
|
|
|
18
18
|
payload = prompt_payload(parts)
|
|
@@ -71,7 +71,7 @@ module Kward
|
|
|
71
71
|
["data", "image_url"].include?(key.to_s)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
def
|
|
74
|
+
def stringify_top_level_keys(value)
|
|
75
75
|
return value unless value.is_a?(Hash)
|
|
76
76
|
|
|
77
77
|
value.each_with_object({}) { |(key, item), result| result[key.to_s] = item }
|
data/lib/kward/model/payloads.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require_relative "../image_attachments"
|
|
3
3
|
require_relative "../message_access"
|
|
4
|
+
require_relative "../tools/tool_call"
|
|
4
5
|
require_relative "model_info"
|
|
5
6
|
|
|
6
7
|
# Namespace for the Kward CLI agent runtime.
|
|
@@ -255,11 +256,7 @@ module Kward
|
|
|
255
256
|
end
|
|
256
257
|
|
|
257
258
|
def parse_tool_arguments(arguments)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
JSON.parse(arguments.to_s.empty? ? "{}" : arguments.to_s)
|
|
261
|
-
rescue JSON::ParserError
|
|
262
|
-
{}
|
|
259
|
+
ToolCall.parse_arguments(arguments)
|
|
263
260
|
end
|
|
264
261
|
|
|
265
262
|
def anthropic_tool_schema(tool)
|
data/lib/kward/prompt_history.rb
CHANGED
|
@@ -11,13 +11,14 @@ module Kward
|
|
|
11
11
|
|
|
12
12
|
Entry = Struct.new(:value, :timestamp, keyword_init: true)
|
|
13
13
|
|
|
14
|
-
def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd, limit: DEFAULT_LIMIT)
|
|
14
|
+
def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd, limit: DEFAULT_LIMIT, kind: "prompt")
|
|
15
15
|
@config_dir = config_dir
|
|
16
16
|
@cwd = ConfigFiles.canonical_workspace_root(cwd)
|
|
17
17
|
@limit = limit.to_i.positive? ? limit.to_i : DEFAULT_LIMIT
|
|
18
|
+
@kind = kind.to_s.empty? ? "prompt" : kind.to_s
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
attr_reader :cwd, :limit
|
|
21
|
+
attr_reader :cwd, :limit, :kind
|
|
21
22
|
|
|
22
23
|
def values
|
|
23
24
|
entries.map(&:value)
|
|
@@ -35,7 +36,7 @@ module Kward
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def path
|
|
38
|
-
ConfigFiles.prompt_history_path(@cwd, config_dir: @config_dir)
|
|
39
|
+
ConfigFiles.prompt_history_path(@cwd, config_dir: @config_dir, kind: @kind)
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
private
|
|
@@ -73,6 +74,7 @@ module Kward
|
|
|
73
74
|
{
|
|
74
75
|
type: "prompt_history_header",
|
|
75
76
|
version: 1,
|
|
77
|
+
kind: @kind,
|
|
76
78
|
workspace: @cwd,
|
|
77
79
|
workspaceHash: File.basename(path, ".jsonl"),
|
|
78
80
|
limit: limit
|
|
@@ -12,8 +12,8 @@ module Kward
|
|
|
12
12
|
LUA_INDENT_KEYWORDS = %w[do else elseif for function if repeat then while].freeze
|
|
13
13
|
SHELL_DEDENT_KEYWORDS = %w[fi done esac].freeze
|
|
14
14
|
PUNCTUATION_PAIRS = { "}" => "{", "]" => "[", ")" => "(" }.freeze
|
|
15
|
-
EDITOR_TAB_SEQUENCES =
|
|
16
|
-
EDITOR_SHIFT_TAB_SEQUENCES =
|
|
15
|
+
EDITOR_TAB_SEQUENCES = TerminalKeys::TAB
|
|
16
|
+
EDITOR_SHIFT_TAB_SEQUENCES = TerminalKeys::SHIFT_TAB
|
|
17
17
|
|
|
18
18
|
private
|
|
19
19
|
|
|
@@ -268,9 +268,10 @@ module Kward
|
|
|
268
268
|
end
|
|
269
269
|
|
|
270
270
|
def editor_indent_unit
|
|
271
|
+
path = @editor_state.path || @editor_state.display_path
|
|
271
272
|
@editor_indent_unit_path ||= nil
|
|
272
|
-
if @editor_indent_unit_path !=
|
|
273
|
-
@editor_indent_unit_path =
|
|
273
|
+
if @editor_indent_unit_path != path
|
|
274
|
+
@editor_indent_unit_path = path
|
|
274
275
|
@editor_indent_unit = detect_editor_indent_unit
|
|
275
276
|
end
|
|
276
277
|
@editor_indent_unit
|