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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/api.md +4 -0
  9. data/doc/composer.md +1 -1
  10. data/doc/configuration.md +68 -21
  11. data/doc/editor.md +28 -13
  12. data/doc/files.md +8 -4
  13. data/doc/getting-started.md +3 -0
  14. data/doc/pan.md +19 -15
  15. data/doc/platform-support.md +48 -0
  16. data/doc/security.md +3 -2
  17. data/doc/shell.md +62 -45
  18. data/doc/troubleshooting.md +12 -2
  19. data/doc/usage.md +5 -5
  20. data/kward.gemspec +5 -4
  21. data/lib/kward/agent.rb +6 -3
  22. data/lib/kward/ansi.rb +110 -10
  23. data/lib/kward/cli/auth_commands.rb +34 -13
  24. data/lib/kward/cli/commands.rb +83 -62
  25. data/lib/kward/cli/doctor.rb +39 -17
  26. data/lib/kward/cli/hook_commands.rb +22 -12
  27. data/lib/kward/cli/interactive_turn.rb +48 -7
  28. data/lib/kward/cli/project_skills_commands.rb +8 -4
  29. data/lib/kward/cli/prompt_interface.rb +27 -0
  30. data/lib/kward/cli/rendering.rb +15 -9
  31. data/lib/kward/cli/runtime_helpers.rb +118 -55
  32. data/lib/kward/cli/slash_commands.rb +12 -14
  33. data/lib/kward/cli/tabs.rb +83 -12
  34. data/lib/kward/cli/tool_summaries.rb +14 -0
  35. data/lib/kward/cli.rb +45 -7
  36. data/lib/kward/cli_transcript_formatter.rb +11 -4
  37. data/lib/kward/config_files.rb +82 -68
  38. data/lib/kward/detached_run.rb +44 -0
  39. data/lib/kward/interactive_pty_runner.rb +102 -28
  40. data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
  41. data/lib/kward/kwshrc.rb +233 -0
  42. data/lib/kward/markdown_code_block.rb +136 -0
  43. data/lib/kward/model/client.rb +34 -22
  44. data/lib/kward/model/provider_catalog.rb +5 -0
  45. data/lib/kward/model/stream_parser.rb +20 -4
  46. data/lib/kward/pan/index.html.erb +3 -3
  47. data/lib/kward/pan/server.rb +23 -3
  48. data/lib/kward/persistent_shell_session.rb +119 -26
  49. data/lib/kward/project_files.rb +2 -2
  50. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  51. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  52. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  53. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  54. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  55. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  56. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  57. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  58. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  59. data/lib/kward/prompt_interface/editor/state.rb +10 -10
  60. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  61. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  62. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  63. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  64. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  65. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  66. data/lib/kward/prompt_interface/runtime_state.rb +50 -1
  67. data/lib/kward/prompt_interface/screen.rb +2 -2
  68. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  69. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  70. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  71. data/lib/kward/prompt_interface.rb +93 -18
  72. data/lib/kward/prompts/commands.rb +1 -1
  73. data/lib/kward/prompts.rb +1 -1
  74. data/lib/kward/pty_output_sink.rb +47 -0
  75. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  76. data/lib/kward/scratchpad_languages.rb +74 -0
  77. data/lib/kward/scratchpad_runner.rb +155 -29
  78. data/lib/kward/shell_prompt.rb +2 -0
  79. data/lib/kward/terminal_keys.rb +12 -0
  80. data/lib/kward/terminal_text.rb +121 -0
  81. data/lib/kward/text_matcher.rb +18 -0
  82. data/lib/kward/tools/open_editor.rb +41 -0
  83. data/lib/kward/tools/registry.rb +13 -4
  84. data/lib/kward/tools/tool_call.rb +2 -1
  85. data/lib/kward/version.rb +1 -1
  86. data/templates/default/fulldoc/html/css/kward.css +0 -125
  87. data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
  88. data/templates/default/fulldoc/html/setup.rb +1 -1
  89. data/templates/default/kward_navigation.rb +1 -0
  90. data/templates/default/layout/html/footer.erb +10 -0
  91. data/templates/default/layout/html/headers.erb +23 -0
  92. data/templates/default/layout/html/layout.erb +6 -18
  93. data/templates/default/layout/html/setup.rb +41 -2
  94. metadata +36 -8
  95. 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 scratchpad buffers and returns transformed buffer content.
8
+ # Executes editor buffers and returns captured runner output.
8
9
  module ScratchpadRunner
9
- RUBY_END_MARKER_PATTERN = /^__END__\n?/.freeze
10
+ OUTPUT_READ_SIZE = 16 * 1024
11
+ MAX_OUTPUT_BYTES = 1_000_000
12
+ POLL_INTERVAL = 0.05
10
13
 
11
- Result = Struct.new(:buffer, :exit_status, keyword_init: true)
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
- case language&.to_sym
17
- when :ruby
18
- run_ruby(content)
19
- else
20
- raise ArgumentError, "Scratchpad language #{language.inspect} is not runnable"
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 run_ruby(content)
25
- content = content.to_s
26
- output, status = capture_ruby_output(content)
27
- Result.new(buffer: ruby_buffer_with_output(content, output, status.exitstatus), exit_status: status.exitstatus)
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 capture_ruby_output(content)
31
- Tempfile.create(["kward-scratchpad", ".rb"]) do |file|
32
- file.write(content)
33
- file.flush
34
- Open3.capture2e(RbConfig.ruby, file.path)
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 ruby_buffer_with_output(content, output, exit_status)
39
- output = output.to_s
40
- output += "\n" unless output.empty? || output.end_with?("\n")
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
- if (match = content.match(RUBY_END_MARKER_PATTERN))
44
- "#{content[0...match.begin(0)]}__END__\n#{output}"
45
- else
46
- "#{content}#{ruby_end_separator(content)}__END__\n#{output}"
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 ruby_end_separator(content)
51
- return "" if content.empty?
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
- content.end_with?("\n") ? "\n" : "\n\n"
178
+ def monotonic_now
179
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
180
  end
55
181
  end
56
182
  end
@@ -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
  }
@@ -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
@@ -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; must implement
77
- # `ask_user_question` before that tool is advertised
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
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.83.0"
4
+ VERSION = "0.84.0"
5
5
  end
@@ -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;