kward 0.83.0 → 0.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/api.md +4 -0
- data/doc/composer.md +1 -1
- data/doc/configuration.md +68 -21
- data/doc/editor.md +28 -13
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/pan.md +19 -15
- data/doc/platform-support.md +48 -0
- data/doc/security.md +3 -2
- data/doc/shell.md +62 -45
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +5 -5
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +6 -3
- data/lib/kward/ansi.rb +110 -10
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +48 -7
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +27 -0
- data/lib/kward/cli/rendering.rb +15 -9
- data/lib/kward/cli/runtime_helpers.rb +118 -55
- data/lib/kward/cli/slash_commands.rb +12 -14
- data/lib/kward/cli/tabs.rb +83 -12
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/cli.rb +45 -7
- data/lib/kward/cli_transcript_formatter.rb +11 -4
- data/lib/kward/config_files.rb +82 -68
- data/lib/kward/detached_run.rb +44 -0
- data/lib/kward/interactive_pty_runner.rb +102 -28
- data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
- data/lib/kward/kwshrc.rb +233 -0
- data/lib/kward/markdown_code_block.rb +136 -0
- data/lib/kward/model/client.rb +34 -22
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +23 -3
- data/lib/kward/persistent_shell_session.rb +119 -26
- data/lib/kward/project_files.rb +2 -2
- data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
- data/lib/kward/prompt_interface/editor/controller.rb +30 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
- data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/state.rb +10 -10
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/file_overlay.rb +71 -15
- data/lib/kward/prompt_interface/key_handler.rb +67 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +50 -1
- data/lib/kward/prompt_interface/screen.rb +2 -2
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +93 -18
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/pty_output_sink.rb +47 -0
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/scratchpad_languages.rb +74 -0
- data/lib/kward/scratchpad_runner.rb +155 -29
- data/lib/kward/shell_prompt.rb +2 -0
- data/lib/kward/terminal_keys.rb +12 -0
- data/lib/kward/terminal_text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/registry.rb +13 -4
- data/lib/kward/tools/tool_call.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/fulldoc/html/css/kward.css +0 -125
- data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
- data/templates/default/fulldoc/html/setup.rb +1 -1
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +6 -18
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +36 -8
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
|
@@ -1,56 +1,182 @@
|
|
|
1
1
|
require "open3"
|
|
2
2
|
require "rbconfig"
|
|
3
3
|
require "tempfile"
|
|
4
|
+
require_relative "scratchpad_languages"
|
|
4
5
|
|
|
5
6
|
# Namespace for the Kward CLI agent runtime.
|
|
6
7
|
module Kward
|
|
7
|
-
# Executes
|
|
8
|
+
# Executes editor buffers and returns captured runner output.
|
|
8
9
|
module ScratchpadRunner
|
|
9
|
-
|
|
10
|
+
OUTPUT_READ_SIZE = 16 * 1024
|
|
11
|
+
MAX_OUTPUT_BYTES = 1_000_000
|
|
12
|
+
POLL_INTERVAL = 0.05
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
DEFAULT_BINARIES = {
|
|
15
|
+
ruby: -> { RbConfig.ruby },
|
|
16
|
+
node: "node",
|
|
17
|
+
python: -> { windows_host? ? "python" : "python3" },
|
|
18
|
+
shell: -> { File.executable?("/bin/sh") ? "/bin/sh" : "sh" },
|
|
19
|
+
lua: "lua",
|
|
20
|
+
julia: "julia",
|
|
21
|
+
elixir: "elixir",
|
|
22
|
+
crystal: "crystal",
|
|
23
|
+
swift: "swift",
|
|
24
|
+
go: "go"
|
|
25
|
+
}.freeze
|
|
26
|
+
RUNNER_ARGUMENTS = {
|
|
27
|
+
crystal: ["run"],
|
|
28
|
+
go: ["run"]
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
Result = Struct.new(:language, :output, :exit_status, :duration, :command, :cancelled, :truncated, keyword_init: true)
|
|
12
32
|
|
|
13
33
|
module_function
|
|
14
34
|
|
|
15
|
-
def run(language, content)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
35
|
+
def run(language, content, cancelled: nil, cwd: Dir.pwd, source_path: nil, runner_config: {})
|
|
36
|
+
language = ScratchpadLanguages.normalize(language)
|
|
37
|
+
runner = ScratchpadLanguages.runner(language)
|
|
38
|
+
raise ArgumentError, "Scratchpad language #{language.inspect} is not runnable" unless runner
|
|
39
|
+
|
|
40
|
+
cwd = File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd)
|
|
41
|
+
binary = configured_binary(runner, runner_config, cwd)
|
|
42
|
+
arguments = RUNNER_ARGUMENTS.fetch(runner, [])
|
|
43
|
+
default_display_path = ScratchpadLanguages.display_path(language)
|
|
44
|
+
display_path = source_path.to_s.empty? ? default_display_path : File.basename(source_path.to_s)
|
|
45
|
+
extension = File.extname(display_path)
|
|
46
|
+
extension = File.extname(default_display_path) if extension.empty?
|
|
47
|
+
started_at = monotonic_now
|
|
48
|
+
output, status, was_cancelled, truncated = capture_source(
|
|
49
|
+
content,
|
|
50
|
+
extension: extension,
|
|
51
|
+
source_path: source_path,
|
|
52
|
+
cwd: cwd,
|
|
53
|
+
command: [binary, *arguments],
|
|
54
|
+
cancelled: cancelled
|
|
55
|
+
)
|
|
56
|
+
Result.new(
|
|
57
|
+
language: language,
|
|
58
|
+
output: output,
|
|
59
|
+
exit_status: was_cancelled ? 130 : status.exitstatus,
|
|
60
|
+
duration: monotonic_now - started_at,
|
|
61
|
+
command: [binary, *arguments, "<#{display_path}>"],
|
|
62
|
+
cancelled: was_cancelled,
|
|
63
|
+
truncated: truncated
|
|
64
|
+
)
|
|
65
|
+
rescue Errno::ENOENT
|
|
66
|
+
raise ArgumentError, "Editor runner #{runner.inspect} binary #{binary.inspect} was not found"
|
|
67
|
+
rescue Errno::EACCES
|
|
68
|
+
raise ArgumentError, "Editor runner #{runner.inspect} binary #{binary.inspect} is not executable"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def capture_source(content, extension:, source_path:, cwd:, command:, cancelled: nil)
|
|
72
|
+
Tempfile.create(["kward-scratchpad", extension], dir: temporary_directory(cwd, source_path)) do |file|
|
|
73
|
+
file.write(content.to_s)
|
|
74
|
+
file.flush
|
|
75
|
+
capture_process([*command, file.path], cancelled: cancelled, chdir: cwd)
|
|
21
76
|
end
|
|
22
77
|
end
|
|
23
78
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
79
|
+
def configured_binary(runner, runner_config, cwd)
|
|
80
|
+
settings = runner_config.is_a?(Hash) ? (runner_config[runner.to_s] || runner_config[runner]) : nil
|
|
81
|
+
configured = settings.is_a?(Hash) ? (settings["binary"] || settings[:binary]) : settings
|
|
82
|
+
binary = configured.nil? ? default_binary(runner) : configured
|
|
83
|
+
unless binary.is_a?(String) && !binary.strip.empty?
|
|
84
|
+
raise ArgumentError, "Editor runner #{runner.inspect} binary must be a non-empty string"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
binary = binary.strip
|
|
88
|
+
path_like?(binary) ? File.expand_path(binary, cwd) : binary
|
|
28
89
|
end
|
|
29
90
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
91
|
+
def default_binary(runner)
|
|
92
|
+
value = DEFAULT_BINARIES[runner]
|
|
93
|
+
value.respond_to?(:call) ? value.call : value
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def temporary_directory(cwd, source_path)
|
|
97
|
+
return cwd if source_path.to_s.empty?
|
|
98
|
+
|
|
99
|
+
directory = File.dirname(File.expand_path(source_path, cwd))
|
|
100
|
+
return directory if Dir.exist?(directory) && File.writable?(directory)
|
|
101
|
+
|
|
102
|
+
cwd
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def path_like?(value)
|
|
106
|
+
value.include?("/") || value.include?("\\") || value.start_with?("~")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def windows_host?
|
|
110
|
+
RbConfig::CONFIG["host_os"].to_s.match?(/mswin|mingw|cygwin/i)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def capture_process(command, cancelled: nil, chdir: nil)
|
|
114
|
+
options = chdir ? { chdir: chdir } : {}
|
|
115
|
+
input, output, wait_thread = Open3.popen2e(*command, **options)
|
|
116
|
+
input.close
|
|
117
|
+
chunks = []
|
|
118
|
+
output_size = 0
|
|
119
|
+
was_cancelled = false
|
|
120
|
+
|
|
121
|
+
loop do
|
|
122
|
+
if !was_cancelled && cancelled&.call
|
|
123
|
+
terminate_process(wait_thread.pid)
|
|
124
|
+
was_cancelled = true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
output_size = read_available_output(output, chunks, output_size)
|
|
128
|
+
break if wait_thread.join(0)
|
|
129
|
+
|
|
130
|
+
sleep POLL_INTERVAL
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
output_size = read_remaining_output(output, chunks, output_size)
|
|
134
|
+
[chunks.join, wait_thread.value, was_cancelled, output_size >= MAX_OUTPUT_BYTES]
|
|
135
|
+
ensure
|
|
136
|
+
if wait_thread&.alive?
|
|
137
|
+
terminate_process(wait_thread.pid)
|
|
138
|
+
wait_thread.join
|
|
35
139
|
end
|
|
140
|
+
input&.close unless input&.closed?
|
|
141
|
+
output&.close unless output&.closed?
|
|
36
142
|
end
|
|
37
143
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
output += "[exit status: #{exit_status}]\n" unless exit_status.to_i.zero?
|
|
144
|
+
def read_available_output(output, chunks, output_size)
|
|
145
|
+
ready = IO.select([output], nil, nil, 0)
|
|
146
|
+
return output_size unless ready
|
|
42
147
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
148
|
+
loop do
|
|
149
|
+
output_size = append_output(chunks, output.read_nonblock(OUTPUT_READ_SIZE), output_size)
|
|
150
|
+
rescue IO::WaitReadable, EOFError
|
|
151
|
+
break
|
|
47
152
|
end
|
|
153
|
+
output_size
|
|
48
154
|
end
|
|
49
155
|
|
|
50
|
-
def
|
|
51
|
-
|
|
156
|
+
def read_remaining_output(output, chunks, output_size)
|
|
157
|
+
loop do
|
|
158
|
+
output_size = append_output(chunks, output.read_nonblock(OUTPUT_READ_SIZE), output_size)
|
|
159
|
+
end
|
|
160
|
+
rescue IO::WaitReadable, IOError, EOFError
|
|
161
|
+
output_size
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def append_output(chunks, chunk, output_size)
|
|
165
|
+
return output_size if output_size >= MAX_OUTPUT_BYTES
|
|
166
|
+
|
|
167
|
+
remaining = MAX_OUTPUT_BYTES - output_size
|
|
168
|
+
chunks << chunk.byteslice(0, remaining)
|
|
169
|
+
output_size + [chunk.bytesize, remaining].min
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def terminate_process(pid)
|
|
173
|
+
Process.kill("TERM", pid)
|
|
174
|
+
rescue Errno::ESRCH
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
52
177
|
|
|
53
|
-
|
|
178
|
+
def monotonic_now
|
|
179
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
180
|
end
|
|
55
181
|
end
|
|
56
182
|
end
|
data/lib/kward/shell_prompt.rb
CHANGED
|
@@ -16,6 +16,8 @@ module Kward
|
|
|
16
16
|
|
|
17
17
|
Use prepare_shell_command when the user asks you to suggest or prepare a command. It places the complete command in the shell prompt but does not execute it; the user must press Enter. Never claim that a prepared command was executed.
|
|
18
18
|
|
|
19
|
+
When available, use open_editor if the user explicitly asks to open an existing workspace file in Kward's integrated editor. Opening the editor does not modify or save the file.
|
|
20
|
+
|
|
19
21
|
Do not execute interactive commands, request passwords, or silently run commands that the user did not explicitly request. If a command needs terminal input, prepare it for the user instead.
|
|
20
22
|
PROMPT
|
|
21
23
|
}
|
data/lib/kward/terminal_keys.rb
CHANGED
|
@@ -9,6 +9,8 @@ module Kward
|
|
|
9
9
|
CTRL_D = "\x04".freeze
|
|
10
10
|
CTRL_E = "\x05".freeze
|
|
11
11
|
CTRL_F = "\x06".freeze
|
|
12
|
+
CTRL_H = "\x08".freeze
|
|
13
|
+
CTRL_J = "\x0A".freeze
|
|
12
14
|
CTRL_K = "\x0B".freeze
|
|
13
15
|
CTRL_L = "\x0C".freeze
|
|
14
16
|
CTRL_N = "\x0E".freeze
|
|
@@ -71,6 +73,16 @@ module Kward
|
|
|
71
73
|
CTRL_T_CSI_U = "\e[116;5u".freeze
|
|
72
74
|
CTRL_W_CSI_U = "\e[119;5u".freeze
|
|
73
75
|
CTRL_NUMBER_TAB_PATTERN = /\A\e\[((?:49)|(?:5[0-7]));5u\z/.freeze
|
|
76
|
+
TAB_ACTION_SEQUENCES = (
|
|
77
|
+
CTRL_TAB +
|
|
78
|
+
CTRL_SHIFT_TAB +
|
|
79
|
+
[CTRL_T, CTRL_T_CSI_U, CTRL_W_CSI_U] +
|
|
80
|
+
(49..57).map { |code| "\e[#{code};5u" } +
|
|
81
|
+
["\et", "\eT"] +
|
|
82
|
+
ALT_RIGHT +
|
|
83
|
+
ALT_LEFT +
|
|
84
|
+
(1..9).map { |number| "\e#{number}" }
|
|
85
|
+
).freeze
|
|
74
86
|
|
|
75
87
|
CSI_U_PATTERN = /\A\e\[(\d+)((?:;[\d:]*)*)u/.freeze
|
|
76
88
|
MODIFIED_CURSOR_PATTERN = /\A\e\[(\d+);(\d+)([CDFH])\z/.freeze
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
require "unicode/display_width"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
# Terminal-cell and grapheme helpers for cursor-aware text layout.
|
|
6
|
+
module TerminalText
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def width(text)
|
|
10
|
+
Unicode::DisplayWidth.of(text.to_s, ambiguous: 1, emoji: :all)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def truncate(text, max_width)
|
|
14
|
+
string = text.to_s
|
|
15
|
+
limit = [max_width.to_i, 0].max
|
|
16
|
+
return string[0, limit].to_s if string.ascii_only?
|
|
17
|
+
|
|
18
|
+
remaining = limit
|
|
19
|
+
truncated = +""
|
|
20
|
+
string.each_grapheme_cluster do |grapheme|
|
|
21
|
+
grapheme_width = width(grapheme)
|
|
22
|
+
break if grapheme_width > remaining
|
|
23
|
+
|
|
24
|
+
truncated << grapheme
|
|
25
|
+
remaining -= grapheme_width
|
|
26
|
+
end
|
|
27
|
+
truncated
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def wrap(text, width:, cursor: nil)
|
|
31
|
+
string = text.to_s
|
|
32
|
+
line_width = [width.to_i, 1].max
|
|
33
|
+
return wrap_ascii(string, line_width, cursor) if string.ascii_only?
|
|
34
|
+
|
|
35
|
+
rows = [+""]
|
|
36
|
+
row_widths = [0]
|
|
37
|
+
cursor_row = nil
|
|
38
|
+
cursor_col = nil
|
|
39
|
+
offset = 0
|
|
40
|
+
|
|
41
|
+
string.each_grapheme_cluster do |grapheme|
|
|
42
|
+
if !cursor.nil? && cursor_row.nil? && cursor <= offset
|
|
43
|
+
append_empty_row(rows, row_widths) if row_widths.last >= line_width
|
|
44
|
+
cursor_row = rows.length - 1
|
|
45
|
+
cursor_col = row_widths.last
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
grapheme_width = width(grapheme)
|
|
49
|
+
append_empty_row(rows, row_widths) if row_widths.last.positive? && row_widths.last + grapheme_width > line_width
|
|
50
|
+
rows.last << grapheme
|
|
51
|
+
row_widths[-1] += grapheme_width
|
|
52
|
+
offset += grapheme.length
|
|
53
|
+
|
|
54
|
+
if !cursor.nil? && cursor_row.nil? && cursor <= offset
|
|
55
|
+
cursor_row = rows.length - 1
|
|
56
|
+
cursor_col = row_widths.last
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if !cursor.nil? && cursor_row.nil?
|
|
61
|
+
append_empty_row(rows, row_widths) if row_widths.last >= line_width
|
|
62
|
+
cursor_row = rows.length - 1
|
|
63
|
+
cursor_col = row_widths.last
|
|
64
|
+
elsif !cursor.nil? && cursor >= offset && row_widths.last >= line_width
|
|
65
|
+
append_empty_row(rows, row_widths)
|
|
66
|
+
cursor_row = rows.length - 1
|
|
67
|
+
cursor_col = 0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{ rows: rows, cursor_row: cursor_row, cursor_col: cursor_col }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def previous_grapheme_boundary(text, index)
|
|
74
|
+
string = text.to_s
|
|
75
|
+
cursor = [[index.to_i, 0].max, string.length].min
|
|
76
|
+
return [cursor - 1, 0].max if string.ascii_only?
|
|
77
|
+
|
|
78
|
+
previous = 0
|
|
79
|
+
offset = 0
|
|
80
|
+
string.each_grapheme_cluster do |grapheme|
|
|
81
|
+
offset += grapheme.length
|
|
82
|
+
return previous if offset >= cursor
|
|
83
|
+
|
|
84
|
+
previous = offset
|
|
85
|
+
end
|
|
86
|
+
previous
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def next_grapheme_boundary(text, index)
|
|
90
|
+
string = text.to_s
|
|
91
|
+
cursor = [[index.to_i, 0].max, string.length].min
|
|
92
|
+
return [cursor + 1, string.length].min if string.ascii_only?
|
|
93
|
+
|
|
94
|
+
offset = 0
|
|
95
|
+
string.each_grapheme_cluster do |grapheme|
|
|
96
|
+
offset += grapheme.length
|
|
97
|
+
return offset if offset > cursor
|
|
98
|
+
end
|
|
99
|
+
string.length
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def wrap_ascii(text, line_width, cursor)
|
|
103
|
+
rows = text.scan(/.{1,#{line_width}}/m)
|
|
104
|
+
rows = [""] if rows.empty?
|
|
105
|
+
return { rows: rows, cursor_row: nil, cursor_col: nil } if cursor.nil?
|
|
106
|
+
|
|
107
|
+
cursor = [[cursor.to_i, 0].max, text.length].min
|
|
108
|
+
if cursor == text.length && text.length.positive? && (text.length % line_width).zero?
|
|
109
|
+
rows << ""
|
|
110
|
+
end
|
|
111
|
+
{ rows: rows, cursor_row: cursor / line_width, cursor_col: cursor % line_width }
|
|
112
|
+
end
|
|
113
|
+
private_class_method :wrap_ascii
|
|
114
|
+
|
|
115
|
+
def append_empty_row(rows, row_widths)
|
|
116
|
+
rows << +""
|
|
117
|
+
row_widths << 0
|
|
118
|
+
end
|
|
119
|
+
private_class_method :append_empty_row
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Allocation-conscious text matching helpers shared by interactive search UI.
|
|
4
|
+
module TextMatcher
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def subsequence_pattern(query)
|
|
8
|
+
source = query.to_s.each_char.map { |character| Regexp.escape(character) }.join(".*")
|
|
9
|
+
Regexp.new(source, Regexp::MULTILINE)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def subsequence?(value, query, pattern = subsequence_pattern(query))
|
|
13
|
+
text = value.to_s
|
|
14
|
+
needle = query.to_s
|
|
15
|
+
needle.empty? || text.match?(pattern)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
# Model-callable tool wrappers and their argument schemas.
|
|
6
|
+
module Tools
|
|
7
|
+
# Tool wrapper for opening a workspace file in the integrated editor.
|
|
8
|
+
class OpenEditor < Base
|
|
9
|
+
# Builds the tool schema and stores the execution dependencies.
|
|
10
|
+
def initialize(workspace:, prompt:)
|
|
11
|
+
@workspace = workspace
|
|
12
|
+
@prompt = prompt
|
|
13
|
+
super(
|
|
14
|
+
"open_editor",
|
|
15
|
+
"Open a workspace file in Kward's integrated editor for the user. Use this when the user explicitly asks to open or edit a file in the built-in editor. This does not itself modify or save the file; the user controls those actions in the editor.",
|
|
16
|
+
properties: {
|
|
17
|
+
path: { type: "string", description: "Workspace-relative path of the file to open." }
|
|
18
|
+
},
|
|
19
|
+
required: ["path"]
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Opens the requested file and returns after the editor closes.
|
|
24
|
+
def call(args, _conversation, cancellation: nil)
|
|
25
|
+
cancellation&.raise_if_cancelled!
|
|
26
|
+
return "Error: open_editor requires interactive editor support." unless @prompt.respond_to?(:edit_file)
|
|
27
|
+
|
|
28
|
+
path = argument(args, :path, "").to_s
|
|
29
|
+
return "Error: open_editor requires a file path." if path.empty?
|
|
30
|
+
|
|
31
|
+
resolved_path = @workspace.resolved_path(path)
|
|
32
|
+
return "Error: not a file: #{path}" unless File.file?(resolved_path)
|
|
33
|
+
|
|
34
|
+
opened = @prompt.edit_file(resolved_path, base_dir: @workspace.root, allow_new: false)
|
|
35
|
+
opened ? "Opened #{path} in the integrated editor." : "Error: could not open #{path} in the integrated editor."
|
|
36
|
+
rescue SecurityError, StandardError => e
|
|
37
|
+
"Error: could not open #{path}: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -19,6 +19,7 @@ require_relative "summarize_file_structure"
|
|
|
19
19
|
require_relative "retrieve_tool_output"
|
|
20
20
|
require_relative "web_search"
|
|
21
21
|
require_relative "write_file"
|
|
22
|
+
require_relative "open_editor"
|
|
22
23
|
require_relative "replace_editor_buffer"
|
|
23
24
|
require_relative "search/code"
|
|
24
25
|
require_relative "search/web"
|
|
@@ -73,8 +74,8 @@ module Kward
|
|
|
73
74
|
# Builds tool objects and the schema list for the current frontend/config.
|
|
74
75
|
#
|
|
75
76
|
# @param workspace [Workspace] filesystem/shell boundary used by local tools
|
|
76
|
-
# @param prompt [Object, nil] interactive prompt bridge;
|
|
77
|
-
# `ask_user_question`
|
|
77
|
+
# @param prompt [Object, nil] interactive prompt bridge; tools such as
|
|
78
|
+
# `ask_user_question` and `open_editor` are advertised only when supported
|
|
78
79
|
# @param web_search [WebSearch] live web search implementation
|
|
79
80
|
# @param web_fetch [WebFetch] specific URL fetch implementation
|
|
80
81
|
# @param code_search [CodeSearch] public source/package search implementation
|
|
@@ -257,7 +258,7 @@ module Kward
|
|
|
257
258
|
"web"
|
|
258
259
|
when Tools::ReadSkill
|
|
259
260
|
"skill"
|
|
260
|
-
when Tools::AskUserQuestion
|
|
261
|
+
when Tools::AskUserQuestion, Tools::OpenEditor
|
|
261
262
|
"ui"
|
|
262
263
|
else
|
|
263
264
|
"builtin"
|
|
@@ -522,6 +523,7 @@ module Kward
|
|
|
522
523
|
def build_schema_tools
|
|
523
524
|
tools = @tools.values_at(*CORE_TOOL_NAMES)
|
|
524
525
|
tools << @tools["replace_editor_buffer"] if @tools["replace_editor_buffer"]
|
|
526
|
+
tools << @tools["open_editor"] if @tools["open_editor"]
|
|
525
527
|
tools << @tools["prepare_shell_command"] if @tools["prepare_shell_command"]
|
|
526
528
|
tools << @tools["git_commit"] if @tools["git_commit"]
|
|
527
529
|
tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
|
|
@@ -557,6 +559,7 @@ module Kward
|
|
|
557
559
|
Tools::RetrieveToolOutput.new
|
|
558
560
|
]
|
|
559
561
|
tools << Tools::ReplaceEditorBuffer.new(editor_prompt_session: @editor_prompt_session) if @editor_prompt_session
|
|
562
|
+
tools << Tools::OpenEditor.new(workspace: @workspace, prompt: @prompt) if editor_available?
|
|
560
563
|
tools << Tools::PrepareShellCommand.new(shell_prompt_session: @shell_prompt_session) if @shell_prompt_session
|
|
561
564
|
tools
|
|
562
565
|
end
|
|
@@ -569,6 +572,10 @@ module Kward
|
|
|
569
572
|
end
|
|
570
573
|
end
|
|
571
574
|
|
|
575
|
+
def editor_available?
|
|
576
|
+
@prompt&.respond_to?(:edit_file)
|
|
577
|
+
end
|
|
578
|
+
|
|
572
579
|
def editor_prompt_tool_names
|
|
573
580
|
readonly_tools = CORE_TOOL_NAMES - %w[write_file edit_file run_shell_command]
|
|
574
581
|
readonly_tools + ["replace_editor_buffer"]
|
|
@@ -576,7 +583,9 @@ module Kward
|
|
|
576
583
|
|
|
577
584
|
def shell_prompt_tool_names
|
|
578
585
|
readonly_tools = CORE_TOOL_NAMES - %w[write_file edit_file run_shell_command]
|
|
579
|
-
readonly_tools + %w[run_shell_command prepare_shell_command]
|
|
586
|
+
tools = readonly_tools + %w[run_shell_command prepare_shell_command]
|
|
587
|
+
tools << "open_editor" if editor_available?
|
|
588
|
+
tools
|
|
580
589
|
end
|
|
581
590
|
|
|
582
591
|
def web_search_available?
|
|
@@ -26,7 +26,8 @@ module Kward
|
|
|
26
26
|
"fetch_raw" => "fetch_raw",
|
|
27
27
|
"git_commit" => "git_commit",
|
|
28
28
|
"read_skill" => "read_skill",
|
|
29
|
-
"ask_user_question" => "ask_user_question"
|
|
29
|
+
"ask_user_question" => "ask_user_question",
|
|
30
|
+
"open_editor" => "open_editor"
|
|
30
31
|
}.freeze
|
|
31
32
|
|
|
32
33
|
module_function
|
data/lib/kward/version.rb
CHANGED
|
@@ -570,127 +570,6 @@ body.kward-docs #footer {
|
|
|
570
570
|
color: var(--kward-accent-bright);
|
|
571
571
|
}
|
|
572
572
|
|
|
573
|
-
/*
|
|
574
|
-
Hero tagline "swosh": cycles each line through three phrases. The first
|
|
575
|
-
line moves right-to-left: "Your terminal.", "Your RPC frontend.", then
|
|
576
|
-
"Your workflow." The second line mirrors it left-to-right: "Your agent.",
|
|
577
|
-
"Your LLM engine.", then "Your harness." Pure CSS — no JS. The global
|
|
578
|
-
@media (prefers-reduced-motion) rule above collapses the animation to
|
|
579
|
-
the static original copy ("Your terminal. Your agent.") so it remains
|
|
580
|
-
accessible and calm.
|
|
581
|
-
*/
|
|
582
|
-
.kward-swosh {
|
|
583
|
-
box-sizing: content-box;
|
|
584
|
-
display: inline-block;
|
|
585
|
-
height: 0.98em;
|
|
586
|
-
line-height: 0.98;
|
|
587
|
-
margin: -0.16em -0.08em;
|
|
588
|
-
overflow: hidden;
|
|
589
|
-
padding: 0.16em 0.08em;
|
|
590
|
-
position: relative;
|
|
591
|
-
vertical-align: top;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
.kward-hero h1 .kward-swosh-text {
|
|
595
|
-
color: var(--kward-ink);
|
|
596
|
-
display: inline-block;
|
|
597
|
-
line-height: 0.98;
|
|
598
|
-
white-space: nowrap;
|
|
599
|
-
will-change: transform, opacity;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
/* The longest text ("Your RPC frontend.") stays in flow to size the box;
|
|
603
|
-
the shorter phrases overlay it absolutely. */
|
|
604
|
-
.kward-swosh-text-a,
|
|
605
|
-
.kward-swosh-text-e {
|
|
606
|
-
left: 0.08em;
|
|
607
|
-
position: absolute;
|
|
608
|
-
top: 0.16em;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
.kward-swosh-text-a {
|
|
612
|
-
animation: kward-swosh-a 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
.kward-swosh-text-b {
|
|
616
|
-
animation: kward-swosh-b 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
|
|
617
|
-
position: relative;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
.kward-swosh-text-e {
|
|
621
|
-
animation: kward-swosh-e 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
@keyframes kward-swosh-a {
|
|
625
|
-
0%, 25% { transform: translateX(0); opacity: 1; filter: none; }
|
|
626
|
-
33% { transform: translateX(-140%); opacity: 0; filter: blur(3px); }
|
|
627
|
-
34%, 92% { transform: translateX(140%); opacity: 0; filter: none; }
|
|
628
|
-
100% { transform: translateX(0); opacity: 1; filter: none; }
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
@keyframes kward-swosh-b {
|
|
632
|
-
0%, 25% { transform: translateX(140%); opacity: 0; filter: none; }
|
|
633
|
-
33% { transform: translateX(0); opacity: 1; filter: none; }
|
|
634
|
-
58% { transform: translateX(0); opacity: 1; filter: none; }
|
|
635
|
-
66% { transform: translateX(-140%); opacity: 0; filter: blur(3px); }
|
|
636
|
-
67%, 100% { transform: translateX(140%); opacity: 0; filter: none; }
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
@keyframes kward-swosh-e {
|
|
640
|
-
0%, 58% { transform: translateX(140%); opacity: 0; filter: none; }
|
|
641
|
-
66% { transform: translateX(0); opacity: 1; filter: none; }
|
|
642
|
-
92% { transform: translateX(0); opacity: 1; filter: none; }
|
|
643
|
-
100% { transform: translateX(-140%); opacity: 0; filter: blur(3px); }
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
/*
|
|
647
|
-
Second line, mirrored left-to-right. All texts keep the accent-bright
|
|
648
|
-
color of the original "Your agent." span.
|
|
649
|
-
*/
|
|
650
|
-
.kward-hero h1 .kward-swosh-text-c,
|
|
651
|
-
.kward-hero h1 .kward-swosh-text-f {
|
|
652
|
-
color: var(--kward-accent-bright);
|
|
653
|
-
left: 0.08em;
|
|
654
|
-
position: absolute;
|
|
655
|
-
top: 0.16em;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
.kward-hero h1 .kward-swosh-text-c {
|
|
659
|
-
animation: kward-swosh-c 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
.kward-hero h1 .kward-swosh-text-d {
|
|
663
|
-
animation: kward-swosh-d 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
|
|
664
|
-
color: var(--kward-accent-bright);
|
|
665
|
-
position: relative;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
.kward-hero h1 .kward-swosh-text-f {
|
|
669
|
-
animation: kward-swosh-f 18s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
@keyframes kward-swosh-c {
|
|
673
|
-
0%, 25% { transform: translateX(0); opacity: 1; filter: none; }
|
|
674
|
-
33% { transform: translateX(140%); opacity: 0; filter: blur(3px); }
|
|
675
|
-
34%, 92% { transform: translateX(-140%); opacity: 0; filter: none; }
|
|
676
|
-
100% { transform: translateX(0); opacity: 1; filter: none; }
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
@keyframes kward-swosh-d {
|
|
680
|
-
0%, 25% { transform: translateX(-140%); opacity: 0; filter: none; }
|
|
681
|
-
33% { transform: translateX(0); opacity: 1; filter: none; }
|
|
682
|
-
58% { transform: translateX(0); opacity: 1; filter: none; }
|
|
683
|
-
66% { transform: translateX(140%); opacity: 0; filter: blur(3px); }
|
|
684
|
-
67%, 100% { transform: translateX(-140%); opacity: 0; filter: none; }
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
@keyframes kward-swosh-f {
|
|
688
|
-
0%, 58% { transform: translateX(-140%); opacity: 0; filter: none; }
|
|
689
|
-
66% { transform: translateX(0); opacity: 1; filter: none; }
|
|
690
|
-
92% { transform: translateX(0); opacity: 1; filter: none; }
|
|
691
|
-
100% { transform: translateX(140%); opacity: 0; filter: blur(3px); }
|
|
692
|
-
}
|
|
693
|
-
|
|
694
573
|
.kward-lede {
|
|
695
574
|
color: var(--kward-ink);
|
|
696
575
|
font-size: 19px;
|
|
@@ -1041,10 +920,6 @@ body.kward-docs .kward-home #footer p {
|
|
|
1041
920
|
font-size: clamp(32px, 10vw, 38px);
|
|
1042
921
|
}
|
|
1043
922
|
|
|
1044
|
-
.kward-swosh {
|
|
1045
|
-
max-width: 100%;
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
923
|
.kward-feature-strip,
|
|
1049
924
|
.kward-capabilities ul {
|
|
1050
925
|
grid-template-columns: 1fr;
|